From 2956757d0097bdee3a43cfa88fdc2e72cf381112 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Nov 2026 10:22:56 +0000 Subject: [PATCH 1/3] fix(skills): plugin launcher and media helpers run the pinned CLI, not a project copy The plugin launcher now starts npx from the home folder, so the project's node_modules, package.json bin and .npmrc cannot choose which CLI npm runs or how it starts it. It names the project in HYPERFRAMES_PLUGIN_CWD and the CLI's bin moves into that folder before anything else runs, then drops the variable so nested runs keep the folder their caller gives them. In --script mode the launcher hands itself over as HYPERFRAMES_CLI_ENTRY. The media helpers (Kokoro voice, transcription, the resolve shim) run the CLI they were handed, and stop with an actionable message when none was, instead of resolving `npx hyperframes` from the project. Co-Authored-By: Claude Opus 5.5 --- packages/cli/bin/hyperframes.mjs | 6 + packages/cli/src/binEntry.test.ts | 58 +++++++++ .../cli/src/media-use/lib/npx-sync.test.mjs | 53 ++++----- .../src/media-use/lib/tts-local-provider.mjs | 27 +++-- .../media-use/lib/tts-local-provider.test.mjs | 112 ++++++------------ skills-manifest.json | 4 +- .../references/plugin-installation.md | 7 +- skills/hyperframes/scripts/plugin-cli.mjs | 20 +++- .../hyperframes/scripts/plugin-cli.test.mjs | 47 +++++++- .../audio/scripts/gemini-pipeline.test.mjs | 12 +- .../audio/scripts/lib/tts.cleanup.test.mjs | 18 ++- skills/media-use/audio/scripts/lib/tts.mjs | 31 ++++- .../audio/scripts/lib/tts.spawn.test.mjs | 49 ++++++-- .../media-use/audio/scripts/lib/tts.test.mjs | 72 ++++++++--- skills/media-use/references/audio.md | 2 + skills/media-use/references/operations.md | 3 +- skills/media-use/scripts/lib/npx-sync.mjs | 14 ++- .../media-use/scripts/resolve-plugin.test.mjs | 60 +++++++--- skills/media-use/scripts/resolve.mjs | 26 ++-- skills/media-use/scripts/transcribe.test.mjs | 77 +++++++++--- 20 files changed, 471 insertions(+), 227 deletions(-) create mode 100644 packages/cli/src/binEntry.test.ts diff --git a/packages/cli/bin/hyperframes.mjs b/packages/cli/bin/hyperframes.mjs index e8fa42661e3..a426d2d4c35 100644 --- a/packages/cli/bin/hyperframes.mjs +++ b/packages/cli/bin/hyperframes.mjs @@ -3,6 +3,12 @@ import module from "node:module"; import { runtimeVersionError } from "../dist/runtimeVersion.js"; +// The plugin launcher starts npx outside the project so npm cannot pick the CLI from it, +// and names the project here. Nested CLI runs keep the working folder their caller gives them. +const projectDir = process.env.HYPERFRAMES_PLUGIN_CWD; +delete process.env.HYPERFRAMES_PLUGIN_CWD; +if (projectDir) process.chdir(projectDir); + const error = runtimeVersionError(process.versions.node); if (error) { console.error(error); diff --git a/packages/cli/src/binEntry.test.ts b/packages/cli/src/binEntry.test.ts new file mode 100644 index 00000000000..a039a81543e --- /dev/null +++ b/packages/cli/src/binEntry.test.ts @@ -0,0 +1,58 @@ +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }))); + +// Runs the published bin over stub dist files from a folder outside the project; the stub CLI +// reports the folder it runs in and whether the launcher's project folder reached it. +function runBin(handover: (project: string) => string | undefined) { + const root = mkdtempSync(join(tmpdir(), "hf-bin-entry-")); + roots.push(root); + const project = join(root, "user project"); + const outside = join(root, "home"); + for (const dir of [project, outside, join(root, "pkg/bin"), join(root, "pkg/dist")]) { + mkdirSync(dir, { recursive: true }); + } + const bin = join(root, "pkg/bin/hyperframes.mjs"); + copyFileSync(fileURLToPath(new URL("../bin/hyperframes.mjs", import.meta.url)), bin); + writeFileSync( + join(root, "pkg/dist/runtimeVersion.js"), + "export const runtimeVersionError = () => null;\n", + ); + writeFileSync( + join(root, "pkg/dist/cli.js"), + "console.log(JSON.stringify({ cwd: process.cwd(), passedOn: process.env.HYPERFRAMES_PLUGIN_CWD ?? null }));\n", + ); + const env = { ...process.env }; + delete env.HYPERFRAMES_PLUGIN_CWD; + const named = handover(project); + if (named !== undefined) env.HYPERFRAMES_PLUGIN_CWD = named; + const result = spawnSync(process.execPath, [bin, "info"], { + cwd: outside, + encoding: "utf8", + env, + }); + expect(result.status, result.stderr).toBe(0); + return { + seen: JSON.parse(result.stdout), + project: realpathSync(project), + outside: realpathSync(outside), + }; +} + +describe("bin/hyperframes.mjs", () => { + it("runs in the project the plugin launcher names, and does not pass that on", () => { + const { seen, project } = runBin((dir) => dir); + expect(seen).toEqual({ cwd: project, passedOn: null }); + }); + + it("stays in its working folder without a launcher", () => { + const { seen, outside } = runBin(() => undefined); + expect(seen).toEqual({ cwd: outside, passedOn: null }); + }); +}); diff --git a/packages/cli/src/media-use/lib/npx-sync.test.mjs b/packages/cli/src/media-use/lib/npx-sync.test.mjs index 83244e642a0..40a9b4b45b4 100644 --- a/packages/cli/src/media-use/lib/npx-sync.test.mjs +++ b/packages/cli/src/media-use/lib/npx-sync.test.mjs @@ -3,9 +3,9 @@ import assert from "node:assert/strict"; import { resolveNpxInvocation } from "./npx-sync.mjs"; // Coverage parity with tts-local-provider.test.mjs, for the whisper.cpp -// fallback's call-site shape (transcribe.mjs runWhisper): the same three -// branches, but with the hard-fail contract — no fallback provider exists, -// so an unresolvable npx must throw actionably instead of returning null. +// fallback's call-site shape (transcribe.mjs runCli), with the hard-fail +// contract: no fallback provider exists, so a missing CLI hand-over throws +// actionably instead of resolving `hyperframes` through npx. const envWithNpxCli = { npm_execpath: "C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js", @@ -17,34 +17,25 @@ const pathExists = (path) => path === npxCliPath; const WHISPER_ARGV = ["hyperframes", "transcribe", "C:\\media\\input.wav", "--dir", "C:\\work"]; const WHISPER_OPTS = { stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 }; -test("win32: routes the whisper fallback through node + npx-cli, never bare npx", () => { - const resolved = resolveNpxInvocation( - WHISPER_ARGV, - WHISPER_OPTS, - "win32", - envWithNpxCli, - pathExists, - ); - - assert.equal(resolved.cmd, envWithNpxCli.npm_node_execpath); - assert.equal(resolved.args[0], npxCliPath); - assert.deepEqual(resolved.args.slice(1, 3), ["hyperframes", "transcribe"]); - // execFileSync options survive the rerouting (the timeout bounds the - // whisper build/model download; the pipes surface its errors). - assert.deepEqual(resolved.opts.stdio, ["ignore", "pipe", "pipe"]); - assert.equal(resolved.opts.timeout, 1_800_000); -}); - -test("win32 without a resolvable npx-cli: throws the actionable install hint", () => { - assert.throws( - () => resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, "win32", {}, () => false), - /npx-cli\.js was not found/, - ); +test("runs the handed-over CLI entry with the same Node, on every platform", () => { + for (const platform of ["darwin", "win32"]) { + const env = { ...envWithNpxCli, HYPERFRAMES_CLI_ENTRY: "/opt/hf/bin/hyperframes.mjs" }; + const resolved = resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, platform, env, pathExists); + + assert.equal(resolved.cmd, process.execPath); + assert.deepEqual(resolved.args, ["/opt/hf/bin/hyperframes.mjs", ...WHISPER_ARGV.slice(1)]); + // execFileSync options survive (the timeout bounds the whisper build/model + // download; the pipes surface its errors). + assert.deepEqual(resolved.opts.stdio, ["ignore", "pipe", "pipe"]); + assert.equal(resolved.opts.timeout, 1_800_000); + } }); -test("non-win32: spawns plain npx unchanged", () => { - const resolved = resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, "darwin", {}, () => false); - - assert.equal(resolved.cmd, "npx"); - assert.deepEqual(resolved.args, WHISPER_ARGV); +test("without a handed-over CLI: throws the fix instead of running npx", () => { + for (const platform of ["darwin", "win32"]) { + assert.throws( + () => resolveNpxInvocation(WHISPER_ARGV, WHISPER_OPTS, platform, envWithNpxCli, pathExists), + /HYPERFRAMES_CLI_ENTRY/, + ); + } }); diff --git a/packages/cli/src/media-use/lib/tts-local-provider.mjs b/packages/cli/src/media-use/lib/tts-local-provider.mjs index d5e9416a73f..d0a2dcda9f2 100644 --- a/packages/cli/src/media-use/lib/tts-local-provider.mjs +++ b/packages/cli/src/media-use/lib/tts-local-provider.mjs @@ -45,17 +45,22 @@ export async function localTtsGenerate( const argv = ["hyperframes", "tts", intent, "--output", outPath]; if (ctx?.voice) argv.push("--voice", ctx.voice); if (ctx?.lang && ctx.lang !== "en") argv.push("--lang", ctx.lang); - // On Windows a bare "npx" is npx.cmd, which execFileSync cannot exec - // (spawnSync npx ENOENT) — resolveSpawnCommand reroutes it through - // node + npx-cli.js, same as the audio engine's TTS spawns. - const resolved = resolveSpawnCommand( - "npx", - argv, - { encoding: "utf8", timeout: 300000, stdio: ["ignore", "pipe", "pipe"] }, - platform, - env, - pathExists, - ); + // resolveSpawnCommand runs the CLI that started this engine (HYPERFRAMES_CLI_ENTRY) with the + // same Node, and throws when none was handed over rather than resolving npx from the project. + let resolved; + try { + resolved = resolveSpawnCommand( + "npx", + argv, + { encoding: "utf8", timeout: 300000, stdio: ["ignore", "pipe", "pipe"] }, + platform, + env, + pathExists, + ); + } catch (err) { + console.error(`media-use: local voice not enabled (kokoro). ${err.message}`); + return null; + } if (!resolved) { // npx-on-win32 with no resolvable npx-cli.js — same terminal condition // spawnP warns about. Fall through to the next provider rather than crash. diff --git a/packages/cli/src/media-use/lib/tts-local-provider.test.mjs b/packages/cli/src/media-use/lib/tts-local-provider.test.mjs index 7ab1996def3..d1c6ad5f3bf 100644 --- a/packages/cli/src/media-use/lib/tts-local-provider.test.mjs +++ b/packages/cli/src/media-use/lib/tts-local-provider.test.mjs @@ -2,11 +2,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { localTtsGenerate } from "./tts-local-provider.mjs"; -// Regression: on Windows, a bare "npx" is npx.cmd, which execFileSync cannot -// exec — the Kokoro delegation failed with `spawnSync npx ENOENT` instead of -// synthesizing (or cleanly falling through to the next provider). The spawn -// must route through node + npx-cli.js on win32, same as the audio engine's -// TTS spawns (see ../../audio/scripts/lib/tts.spawn.test.mjs). +// Kokoro runs `hyperframes tts` through the CLI that started the engine (HYPERFRAMES_CLI_ENTRY), +// with the same Node and no shell, on every platform. Without that hand-over it never resolves +// `hyperframes` through npx from the project; it falls through to the next provider. const envWithNpxCli = { npm_execpath: "C:/Program Files/nodejs/node_modules/npm/bin/npm-cli.js", @@ -15,89 +13,47 @@ const envWithNpxCli = { const npxCliPath = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js"; const pathExists = (path) => path === npxCliPath; -test("win32: routes the hyperframes tts call through node + npx-cli, never bare npx", async () => { - const captured = []; - const fakeExec = (cmd, args, opts) => { - captured.push({ cmd, args, opts }); - // Synthesize nothing — the provider then returns null via the - // missing-output check, which is fine: we only assert the spawn shape. - }; - - await localTtsGenerate( - "hello there", - { voice: "am_michael" }, - "win32", - fakeExec, - envWithNpxCli, - pathExists, - ); - - assert.equal(captured.length, 1); - assert.equal(captured[0].cmd, envWithNpxCli.npm_node_execpath); - assert.equal(captured[0].args[0], npxCliPath); - assert.deepEqual(captured[0].args.slice(1, 4), ["hyperframes", "tts", "hello there"]); - assert.ok(captured[0].args.includes("--voice")); - // execFileSync options survive the rerouting (pipes are what let the caller - // read the "kokoro-onnx not installed" hint back out). - assert.deepEqual(captured[0].opts.stdio, ["ignore", "pipe", "pipe"]); -}); - -test("win32 without a resolvable npx-cli: falls through to the next provider (null), no spawn", async () => { - const captured = []; - const fakeExec = (...call) => captured.push(call); - - const result = await localTtsGenerate( - "hello", - {}, - "win32", - fakeExec, - {}, // no npm_execpath, and pathExists finds nothing - () => false, - ); - - assert.equal(result, null); - assert.equal(captured.length, 0); -}); - -test("non-win32: spawns plain npx unchanged", async () => { - const captured = []; - const fakeExec = (cmd, args) => captured.push({ cmd, args }); - - await localTtsGenerate("hola", { lang: "es" }, "darwin", fakeExec, {}, () => false); - - assert.equal(captured.length, 1); - assert.equal(captured[0].cmd, "npx"); - assert.deepEqual(captured[0].args.slice(0, 3), ["hyperframes", "tts", "hola"]); - assert.ok(captured[0].args.includes("--lang")); -}); - test("a launching CLI's entry file runs instead of npx, on every platform", async () => { for (const platform of ["darwin", "linux", "win32"]) { const captured = []; - const fakeExec = (cmd, args) => captured.push({ cmd, args }); + const fakeExec = (cmd, args, opts) => captured.push({ cmd, args, opts }); const entry = "/opt/hf/dist/cli.js"; const env = { ...envWithNpxCli, HYPERFRAMES_CLI_ENTRY: entry }; - await localTtsGenerate("hi", {}, platform, fakeExec, env, pathExists); + await localTtsGenerate( + "hello & there", + { voice: "am_michael" }, + platform, + fakeExec, + env, + pathExists, + ); assert.equal(captured.length, 1); assert.equal(captured[0].cmd, process.execPath); - assert.deepEqual(captured[0].args.slice(0, 3), [entry, "tts", "hi"]); + assert.deepEqual(captured[0].args.slice(0, 3), [entry, "tts", "hello & there"]); + assert.ok(captured[0].args.includes("--voice")); + // execFileSync options survive (pipes are what let the caller read the + // "kokoro-onnx not installed" hint back out). + assert.deepEqual(captured[0].opts.stdio, ["ignore", "pipe", "pipe"]); } }); -test("a relative CLI entry is ignored and npx resolves as before", async () => { - const captured = []; - const fakeExec = (cmd, args) => captured.push({ cmd, args }); - - await localTtsGenerate( - "hi", - {}, - "darwin", - fakeExec, - { HYPERFRAMES_CLI_ENTRY: "cli.js" }, - () => false, - ); - - assert.equal(captured[0].cmd, "npx"); +test("without an absolute CLI entry: falls through to the next provider (null), no spawn", async () => { + for (const env of [envWithNpxCli, { ...envWithNpxCli, HYPERFRAMES_CLI_ENTRY: "cli.js" }]) { + for (const platform of ["darwin", "win32"]) { + const captured = []; + const errors = []; + const originalError = console.error; + console.error = (message) => errors.push(String(message)); + try { + const fakeExec = (...call) => captured.push(call); + assert.equal(await localTtsGenerate("hi", {}, platform, fakeExec, env, pathExists), null); + } finally { + console.error = originalError; + } + assert.equal(captured.length, 0); + assert.match(errors.join("\n"), /HYPERFRAMES_CLI_ENTRY/); + } + } }); diff --git a/skills-manifest.json b/skills-manifest.json index e4e9ed57c26..23ee47b0a5a 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 2 }, "hyperframes": { - "hash": "3abc3f132de58c47", + "hash": "5255c748a61c5fcd", "files": 78 }, "hyperframes-cli": { @@ -18,7 +18,7 @@ "files": 13 }, "media-use": { - "hash": "c72fc9eddec1b5ed", + "hash": "25f93e8726681a5b", "files": 122 }, "remotion-to-hyperframes": { diff --git a/skills/hyperframes/references/plugin-installation.md b/skills/hyperframes/references/plugin-installation.md index ef2144b0e26..89262edab77 100644 --- a/skills/hyperframes/references/plugin-installation.md +++ b/skills/hyperframes/references/plugin-installation.md @@ -23,13 +23,16 @@ For a plugin installation, these rules apply in every skill and reference: Run in the user's project directory, never in the plugin directory. The launcher selects the manifest's CLI version and disables automatic standalone skill installation, including during `init`. The first call may download that exact - CLI version through npm. A download failure is an error, not permission to use + CLI version through npm. npm runs from the home folder, so the project's + `node_modules`, `package.json` and `.npmrc` cannot choose the CLI; the CLI then + works in the project. A download failure is an error, not permission to use latest. Existing project dependency files and lockfiles must not be overwritten to match the plugin; surface a runtime incompatibility before changing them. - For bundled Node helpers, use the same launcher with `--script `. This passes the - release version to their dependency loader. External providers, registry + release version to their dependency loader, and helpers that call the CLI run + it through the launcher. External providers, registry downloads, and existing project dependencies have their own versions; a plugin version alone does not freeze those inputs. - Treat the plugin directory as read-only. Put outputs and temporary work in the diff --git a/skills/hyperframes/scripts/plugin-cli.mjs b/skills/hyperframes/scripts/plugin-cli.mjs index fee0ceec00a..4aaff183556 100644 --- a/skills/hyperframes/scripts/plugin-cli.mjs +++ b/skills/hyperframes/scripts/plugin-cli.mjs @@ -2,6 +2,7 @@ // Run from the user's project; locate the release from this installed file. import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; import { join, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { isMainModule } from "./lib/main-module.mjs"; @@ -33,6 +34,12 @@ export function pluginVersion(pluginRoot = root) { ); } +// npm takes the package, bin links and .npmrc from the folder it starts in, so npx starts +// in the home folder and the CLI moves into the project before it runs a command. +function npmFolder(home = homedir(), pathExists = existsSync) { + return home && pathExists(home) ? home : tmpdir(); +} + export function invocation( args, { @@ -41,6 +48,9 @@ export function invocation( platform = process.platform, node = process.execPath, pathExists = existsSync, + cwd = process.cwd(), + npmCwd = npmFolder(), + launcher = fileURLToPath(import.meta.url), } = {}, ) { if (args[0] === "skills") @@ -56,10 +66,13 @@ export function invocation( }; if (args[0] === "--script") { if (!args[1]) throw new Error("--script requires a Node script path."); - return { command: node, args: args.slice(1), env: childEnv }; + // Helpers that call the CLI run it through this launcher, never a `hyperframes` from the project. + const env = { ...childEnv, HYPERFRAMES_CLI_ENTRY: launcher }; + return { command: node, args: args.slice(1), env, cwd }; } const cliArgs = ["--yes", `hyperframes@${version}`, ...args]; - if (platform !== "win32") return { command: "npx", args: cliArgs, env: childEnv }; + const cliEnv = { ...childEnv, HYPERFRAMES_PLUGIN_CWD: cwd }; + if (platform !== "win32") return { command: "npx", args: cliArgs, env: cliEnv, cwd: npmCwd }; const candidates = [ env.npm_execpath && win32.join(win32.dirname(env.npm_execpath), "npx-cli.js"), win32.join(win32.dirname(node), "node_modules", "npm", "bin", "npx-cli.js"), @@ -69,13 +82,14 @@ export function invocation( throw new Error( "Cannot find npx-cli.js. Install Node.js with npm or run from an npm environment.", ); - return { command: node, args: [npx, ...cliArgs], env: childEnv }; + return { command: node, args: [npx, ...cliArgs], env: cliEnv, cwd: npmCwd }; } if (isMainModule(import.meta.url)) { try { const command = invocation(process.argv.slice(2)); const result = spawnSync(command.command, command.args, { + cwd: command.cwd, env: command.env, stdio: "inherit", windowsHide: true, diff --git a/skills/hyperframes/scripts/plugin-cli.test.mjs b/skills/hyperframes/scripts/plugin-cli.test.mjs index d1a42cc5f35..825168d7321 100644 --- a/skills/hyperframes/scripts/plugin-cli.test.mjs +++ b/skills/hyperframes/scripts/plugin-cli.test.mjs @@ -9,7 +9,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { test } from "node:test"; import { invocation, pluginVersion } from "./plugin-cli.mjs"; @@ -54,8 +54,11 @@ test("pins CLI, suppresses standalone refresh, preserves unrelated environment", version: "1.2.3", platform: "darwin", env: { PATH: "/bin", HYPERFRAMES_SKIP_SKILLS: "0" }, + cwd: "/work/project", + npmCwd: "/home/person", }); assert.equal(result.command, "npx"); + assert.equal(result.cwd, "/home/person"); assert.deepEqual(result.args, [ "--yes", "hyperframes@1.2.3", @@ -69,6 +72,7 @@ test("pins CLI, suppresses standalone refresh, preserves unrelated environment", HYPERFRAMES_SKILL_PKG_VERSION: "1.2.3", HYPERFRAMES_PLUGIN_VERSION: "1.2.3", HYPERFRAMES_NO_UPDATE_CHECK: "1", + HYPERFRAMES_PLUGIN_CWD: "/work/project", }); assert.throws(() => invocation(["skills", "update"], { version: "1.2.3" }), /plugin manager/); }); @@ -80,8 +84,10 @@ test("Windows launches npx through Node without shell argument interpretation", node: String.raw`C:\Program Files\nodejs\node.exe`, env: { npm_execpath: String.raw`C:\Program Files\nodejs\node_modules\npm\bin\npm-cli.js` }, pathExists: (p) => p === String.raw`C:\Program Files\nodejs\node_modules\npm\bin\npx-cli.js`, + npmCwd: String.raw`C:\Users\person`, }); assert.equal(result.command, String.raw`C:\Program Files\nodejs\node.exe`); + assert.equal(result.cwd, String.raw`C:\Users\person`); assert.deepEqual(result.args, [ String.raw`C:\Program Files\nodejs\node_modules\npm\bin\npx-cli.js`, "--yes", @@ -102,7 +108,7 @@ test("installed helper keeps cwd, arguments, release environment and exit code", const helper = join(root, "helper.mjs"); writeFileSync( helper, - "console.log(JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2), skip: process.env.HYPERFRAMES_SKIP_SKILLS, version: process.env.HYPERFRAMES_SKILL_PKG_VERSION })); process.exit(7);", + "console.log(JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2), skip: process.env.HYPERFRAMES_SKIP_SKILLS, version: process.env.HYPERFRAMES_SKILL_PKG_VERSION, cli: process.env.HYPERFRAMES_CLI_ENTRY })); process.exit(7);", ); const before = readFileSync(join(root, "plugin.json"), "utf8"); const result = spawnSync( @@ -116,6 +122,43 @@ test("installed helper keeps cwd, arguments, release environment and exit code", args: ["literal $value & spaces"], skip: "1", version: "1.2.3", + cli: realpathSync(launcher), }); assert.equal(readFileSync(join(root, "plugin.json"), "utf8"), before); }); + +test( + "npx starts outside the project, so its packages, bin links and .npmrc cannot pick the CLI", + { skip: process.platform === "win32" }, + (t) => { + const { root, launcher } = fixture(t); + const home = join(root, "home"); + const bin = join(root, "bin"); + const project = join(root, "user project"); + mkdirSync(home); + mkdirSync(bin); + mkdirSync(join(project, "node_modules/.bin"), { recursive: true }); + writeFileSync( + join(project, ".npmrc"), + "registry=http://127.0.0.1:9/\nscript-shell=/bin/false\n", + ); + writeFileSync(join(project, "package.json"), '{"name":"hyperframes","bin":"x.js"}'); + writeFileSync(join(project, "node_modules/.bin/hyperframes"), "#!/bin/sh\n", { mode: 0o755 }); + writeFileSync( + join(bin, "npx"), + `#!${process.execPath}\nconsole.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2),project:process.env.HYPERFRAMES_PLUGIN_CWD}));`, + { mode: 0o755 }, + ); + const result = spawnSync(process.execPath, [launcher, "--version"], { + cwd: project, + encoding: "utf8", + env: { ...process.env, HOME: home, PATH: `${bin}${delimiter}${process.env.PATH}` }, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + cwd: realpathSync(home), + args: ["--yes", "hyperframes@1.2.3", "--version"], + project: realpathSync(project), + }); + }, +); diff --git a/skills/media-use/audio/scripts/gemini-pipeline.test.mjs b/skills/media-use/audio/scripts/gemini-pipeline.test.mjs index a277457d9d5..551ad3ea270 100644 --- a/skills/media-use/audio/scripts/gemini-pipeline.test.mjs +++ b/skills/media-use/audio/scripts/gemini-pipeline.test.mjs @@ -47,21 +47,20 @@ globalThis.fetch = async (url, options) => { }; `, ); + const cli = join(bin, "cli.cjs"); writeFileSync( - join(bin, "npx"), - `#!${process.execPath} -const assert = require('node:assert/strict'); + cli, + `const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); -assert.deepEqual(args.slice(0, 3), ['hyperframes', 'transcribe', '${voicePath}']); -assert.ok(fs.existsSync(args[2])); +assert.deepEqual(args.slice(0, 2), ['transcribe', '${voicePath}']); +assert.ok(fs.existsSync(args[1])); assert.equal(args[args.indexOf('--model')+1], 'small.en'); fs.writeFileSync(path.join(args[args.indexOf('--dir')+1], 'transcript.json'), JSON.stringify([ {text:'Hello',start:0.1,end:0.4}, {text:'there.',start:0.5,end:0.9} ])); `, - { mode: 0o755 }, ); writeFileSync(join(bin, "ffprobe"), "#!/bin/sh\necho 1.25\n", { mode: 0o755 }); writeFileSync(join(dir, ".env"), ""); @@ -95,6 +94,7 @@ fs.writeFileSync(path.join(args[args.indexOf('--dir')+1], 'transcript.json'), JS HYPERFRAMES_API_KEY: "", GEMINI_API_KEY: "fixture-key", PATH: `${bin}:${process.env.PATH}`, + HYPERFRAMES_CLI_ENTRY: cli, }, }, ); diff --git a/skills/media-use/audio/scripts/lib/tts.cleanup.test.mjs b/skills/media-use/audio/scripts/lib/tts.cleanup.test.mjs index 1275e60706c..fc45659da8c 100644 --- a/skills/media-use/audio/scripts/lib/tts.cleanup.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.cleanup.test.mjs @@ -95,8 +95,10 @@ if (["allocation", "write", "spawn-throw"].includes(mode)) { const result = await request(); const label = provider === "elevenlabs" ? "elevenlabs (python)" : "kokoro (npx hyperframes tts)"; if (["nonzero", "enoent", "no-output"].includes(mode)) { + // Kokoro runs the handed-over CLI with this Node, so a missing CLI file exits 1, not ENOENT. + const missing = provider === "kokoro" ? 1 : -1; const error = mode === "no-output" ? label + " produced no wav file" : - label + " exited with status " + (mode === "enoent" ? -1 : 7); + label + " exited with status " + (mode === "enoent" ? missing : 7); assert.deepEqual(result, { ok: false, words: null, error }); assert.equal(fs.existsSync(wavAbs), false); } else { @@ -120,11 +122,14 @@ function runCase(provider, mode) { try { for (const dir of ["home", "tmp", "bin", "project"]) mkdirSync(join(root, dir)); if (mode !== "enoent") { - for (const name of ["npx", "python3"]) { - const executable = join(root, "bin", name); - writeFileSync(executable, stub); - chmodSync(executable, 0o755); - } + const python = join(root, "bin", "python3"); + writeFileSync(python, stub); + chmodSync(python, 0o755); + // The handed-over CLI sees `tts ...`; the shared stub reads npx-style argv. + writeFileSync( + join(root, "bin", "cli.cjs"), + 'process.argv.splice(2, 0, "hyperframes");\nrequire("./python3");\n', + ); } const result = spawnSync(process.execPath, ["--input-type=module", "-e", exercise], { encoding: "utf8", @@ -136,6 +141,7 @@ function runCase(provider, mode) { TMP: join(root, "tmp"), TEMP: join(root, "tmp"), PATH: join(root, "bin"), + HYPERFRAMES_CLI_ENTRY: join(root, "bin", "cli.cjs"), CASE_ROOT: root, CASE_PROVIDER: provider, CASE_MODE: mode, diff --git a/skills/media-use/audio/scripts/lib/tts.mjs b/skills/media-use/audio/scripts/lib/tts.mjs index f7d85a3e003..043aef06629 100644 --- a/skills/media-use/audio/scripts/lib/tts.mjs +++ b/skills/media-use/audio/scripts/lib/tts.mjs @@ -146,11 +146,18 @@ export function resolveNpxCliPath( return pathExists(besideNode) ? besideNode : null; } -// A HyperFrames CLI that launches this code hands over its own entry file, so `npx hyperframes` -// runs that same build with the same Node instead of whatever npx resolves from the project. +export const MISSING_CLI_MESSAGE = + "No HyperFrames CLI was handed to this helper, and it does not look one up in the project. " + + "Run the helper through the plugin launcher (`plugin-cli.mjs --script ...`), or set " + + "HYPERFRAMES_CLI_ENTRY to the absolute path of your installed CLI's bin/hyperframes.mjs."; + +// The HyperFrames CLI or plugin launcher that starts this code hands over its own entry file, so +// `npx hyperframes` runs that same CLI with the same Node. Without one it fails rather than +// letting npx resolve `hyperframes` from the project. function launchingCliCommand(cmd, args, env = process.env) { + if (cmd !== "npx" || args[0] !== "hyperframes") return null; const entry = env.HYPERFRAMES_CLI_ENTRY; - if (cmd !== "npx" || args[0] !== "hyperframes" || !entry || !isAbsolute(entry)) return null; + if (!entry || !isAbsolute(entry)) throw new Error(MISSING_CLI_MESSAGE); return { cmd: process.execPath, args: [entry, ...args.slice(1).map((arg) => String(arg))] }; } @@ -186,9 +193,11 @@ export function resolveSpawnCommand( // (its ESM exports are non-configurable, so mock.method can't patch it). // One-shot so a whole batch of TTS lines doesn't repeat the same diagnostic. let _warnedNpxResolution = false; -/** Test-only: reset the one-shot npx-resolution warning latch. */ +let _warnedMissingCli = false; +/** Test-only: reset the one-shot npx-resolution warning latches. */ export function _resetNpxResolutionWarnForTests() { _warnedNpxResolution = false; + _warnedMissingCli = false; } export function spawnP( @@ -200,7 +209,16 @@ export function spawnP( env = process.env, pathExists = existsSync, ) { - const resolved = resolveSpawnCommand(cmd, args, opts, platform, env, pathExists); + let resolved; + try { + resolved = resolveSpawnCommand(cmd, args, opts, platform, env, pathExists); + } catch (error) { + if (!_warnedMissingCli) { + _warnedMissingCli = true; + console.error(`[media-use] ${error.message}`); + } + return Promise.resolve({ status: -1, error: error.message }); + } if (!resolved) { // resolveSpawnCommand only returns null for the npx-on-win32 case where // neither npm's configured CLI nor the beside-node fallback exists. Without @@ -307,7 +325,8 @@ export async function synthesizeOne({ export function synthResult(r, wavAbs, label) { if (r.status === 0 && existsSync(wavAbs)) return { ok: true, words: null }; const why = - r.status !== 0 ? `${label} exited with status ${r.status}` : `${label} produced no wav file`; + r.error ?? + (r.status !== 0 ? `${label} exited with status ${r.status}` : `${label} produced no wav file`); return { ok: false, words: null, error: why }; } diff --git a/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs b/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs index b8e6b49a9bf..d92546efb50 100644 --- a/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs @@ -46,7 +46,7 @@ test("resolveNpxCliPath finds npx-cli beside node when npm_execpath is unset", ( 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"], + ["example-tool", "tts", "C:\\Users\\Test User\\line.txt", "--voice", "am_michael"], {}, "win32", envWithNpxCli, @@ -56,7 +56,7 @@ test("resolveSpawnCommand routes npx through node+npx-cli on win32 without shell assert.equal(resolved.cmd, envWithNpxCli.npm_node_execpath); assert.deepEqual(resolved.args, [ npxCliPath, - "hyperframes", + "example-tool", "tts", "C:\\Users\\Test User\\line.txt", "--voice", @@ -68,21 +68,21 @@ test("resolveSpawnCommand routes npx through node+npx-cli on win32 without shell test("resolveSpawnCommand preserves Windows npx shell metacharacters as argv data", () => { const resolved = resolveSpawnCommand( "npx", - ["hyperframes", "tts", "hello & calc"], + ["example-tool", "tts", "hello & calc"], {}, "win32", envWithNpxCli, pathExists, ); assert.ok(resolved); - assert.deepEqual(resolved.args, [npxCliPath, "hyperframes", "tts", "hello & calc"]); + assert.deepEqual(resolved.args, [npxCliPath, "example-tool", "tts", "hello & calc"]); }); test("spawnP uses the resolved node+npx-cli command for npx on win32", async () => { const captured = []; await spawnP( "npx", - ["hyperframes", "tts"], + ["example-tool", "tts"], {}, "win32", fakeSpawn(captured), @@ -91,15 +91,15 @@ test("spawnP uses the resolved node+npx-cli command for npx on win32", async () ); assert.equal(captured.length, 1); assert.equal(captured[0].cmd, envWithNpxCli.npm_node_execpath); - assert.deepEqual(captured[0].args, [npxCliPath, "hyperframes", "tts"]); + assert.deepEqual(captured[0].args, [npxCliPath, "example-tool", "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)); + await spawnP("npx", ["example-tool", "tts"], {}, "darwin", fakeSpawn(captured)); assert.equal(captured[0].cmd, "npx"); - assert.deepEqual(captured[0].args, ["hyperframes", "tts"]); + assert.deepEqual(captured[0].args, ["example-tool", "tts"]); assert.equal(captured[0].opts.shell, undefined); }); @@ -118,7 +118,7 @@ test("spawnP resolves npx beside node when npm_execpath is unset on win32", asyn const npxCli = "C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js"; const result = await spawnP( "npx", - ["hyperframes", "tts"], + ["example-tool", "tts"], {}, "win32", fakeSpawn(captured), @@ -128,7 +128,7 @@ test("spawnP resolves npx beside node when npm_execpath is unset on win32", asyn assert.equal(result.status, 0); assert.equal(captured.length, 1); assert.equal(captured[0].cmd, node); - assert.deepEqual(captured[0].args, [npxCli, "hyperframes", "tts"]); + assert.deepEqual(captured[0].args, [npxCli, "example-tool", "tts"]); }); test("spawnP warns once with an accurate diagnostic when neither npx path exists", async () => { @@ -140,12 +140,12 @@ test("spawnP warns once with an accurate diagnostic when neither npx path exists const env = { npm_execpath: "C:/missing/npm-cli.js", npm_node_execpath: "C:/node/node.exe" }; const missing = () => false; assert.equal( - (await spawnP("npx", ["hyperframes", "tts"], {}, "win32", fakeSpawn([]), env, missing)) + (await spawnP("npx", ["example-tool", "tts"], {}, "win32", fakeSpawn([]), env, missing)) .status, -1, ); assert.equal( - (await spawnP("npx", ["hyperframes", "tts"], {}, "win32", fakeSpawn([]), env, missing)) + (await spawnP("npx", ["example-tool", "tts"], {}, "win32", fakeSpawn([]), env, missing)) .status, -1, ); @@ -156,3 +156,28 @@ test("spawnP warns once with an accurate diagnostic when neither npx path exists assert.match(errors[0], /npm_execpath \(C:\/missing\/npm-cli\.js\)/); assert.doesNotMatch(errors[0], /npm_execpath is not set/); }); + +test("npx hyperframes runs the CLI handed over in HYPERFRAMES_CLI_ENTRY on every platform", () => { + for (const platform of ["darwin", "linux", "win32"]) { + const env = { ...envWithNpxCli, HYPERFRAMES_CLI_ENTRY: "/opt/hf/bin/hyperframes.mjs" }; + const resolved = resolveSpawnCommand( + "npx", + ["hyperframes", "tts", "a & b"], + {}, + platform, + env, + pathExists, + ); + assert.equal(resolved.cmd, process.execPath); + assert.deepEqual(resolved.args, ["/opt/hf/bin/hyperframes.mjs", "tts", "a & b"]); + } +}); + +test("npx hyperframes without a handed-over CLI throws the fix instead of resolving it", () => { + for (const env of [{}, { HYPERFRAMES_CLI_ENTRY: "bin/hyperframes.mjs" }]) { + assert.throws( + () => resolveSpawnCommand("npx", ["hyperframes", "tts"], {}, "darwin", env, pathExists), + /HYPERFRAMES_CLI_ENTRY/, + ); + } +}); diff --git a/skills/media-use/audio/scripts/lib/tts.test.mjs b/skills/media-use/audio/scripts/lib/tts.test.mjs index 93b0d746da1..d7a0b0b60ef 100644 --- a/skills/media-use/audio/scripts/lib/tts.test.mjs +++ b/skills/media-use/audio/scripts/lib/tts.test.mjs @@ -18,6 +18,8 @@ import { synthesizeHeygen, synthResult, pickProvider, + transcribeWav, + _resetNpxResolutionWarnForTests, } from "./tts.mjs"; test("parseFfmpegDurationBanner reads ffmpeg's stderr Duration line", () => { @@ -165,17 +167,20 @@ test("synthResult names a non-zero subprocess exit", () => { assert.match(res.error, /kokoro .* exited with status 2/); }); -// Kokoro shells out to `npx hyperframes tts`; a fake npx on PATH records the argv -// synthesizeOne really builds, so a dropped flag fails here. Windows runs npx via -// node directly, so the stub never resolves there. +// Kokoro runs `hyperframes tts` through the CLI its caller hands over in HYPERFRAMES_CLI_ENTRY; a +// fake entry records the argv synthesizeOne really builds, so a dropped flag fails here. async function kokoroArgv(options) { const dir = mkdtempSync(join(tmpdir(), "tts-kokoro-argv-")); const argvLog = join(dir, "argv.txt"); - writeFileSync(join(dir, "npx"), `#!/bin/sh\nprintf '%s\\n' "$@" > "${argvLog}"\n`); - chmodSync(join(dir, "npx"), 0o755); - const originalPath = process.env.PATH; + const cli = join(dir, "cli.mjs"); + writeFileSync( + cli, + `import { writeFileSync } from "node:fs";\n` + + `writeFileSync(${JSON.stringify(argvLog)}, process.argv.slice(2).join("\\n"));\n`, + ); + const originalEntry = process.env.HYPERFRAMES_CLI_ENTRY; try { - process.env.PATH = dir; + process.env.HYPERFRAMES_CLI_ENTRY = cli; await synthesizeOne({ provider: "kokoro", text: "hi", @@ -186,25 +191,56 @@ async function kokoroArgv(options) { }); return readFileSync(argvLog, "utf8").trim().split("\n"); } finally { - process.env.PATH = originalPath; + if (originalEntry === undefined) delete process.env.HYPERFRAMES_CLI_ENTRY; + else process.env.HYPERFRAMES_CLI_ENTRY = originalEntry; rmSync(dir, { recursive: true, force: true }); } } -test( - "synthesizeOne(kokoro) forwards a non-default speed to the CLI", - { skip: process.platform === "win32" }, - async () => { - const argv = await kokoroArgv({ speed: 0.8, lang: "es" }); - assert.deepEqual(argv.slice(-4), ["--lang", "es", "--speed", "0.8"]); - }, -); +test("synthesizeOne(kokoro) forwards a non-default speed to the CLI", async () => { + const argv = await kokoroArgv({ speed: 0.8, lang: "es" }); + assert.equal(argv[0], "tts"); + assert.deepEqual(argv.slice(-4), ["--lang", "es", "--speed", "0.8"]); +}); + +test("synthesizeOne(kokoro) leaves --speed off at the default", async () => { + assert.ok(!(await kokoroArgv({})).includes("--speed")); +}); test( - "synthesizeOne(kokoro) leaves --speed off at the default", + "kokoro and transcription without a handed-over CLI fail with the fix, never npx", { skip: process.platform === "win32" }, async () => { - assert.ok(!(await kokoroArgv({})).includes("--speed")); + const dir = mkdtempSync(join(tmpdir(), "tts-kokoro-no-cli-")); + const marker = join(dir, "npx-ran"); + writeFileSync(join(dir, "npx"), `#!/bin/sh\ntouch "${marker}"\n`); + chmodSync(join(dir, "npx"), 0o755); + const saved = { PATH: process.env.PATH, entry: process.env.HYPERFRAMES_CLI_ENTRY }; + const originalError = console.error; + const errors = []; + try { + process.env.PATH = dir; + delete process.env.HYPERFRAMES_CLI_ENTRY; + console.error = (message) => errors.push(String(message)); + _resetNpxResolutionWarnForTests(); + const res = await synthesizeOne({ + provider: "kokoro", + text: "hi", + voiceId: "am_michael", + wavAbs: join(dir, "line-0.wav"), + hyperframesDir: dir, + }); + assert.equal(res.ok, false); + assert.match(res.error, /HYPERFRAMES_CLI_ENTRY/); + assert.equal(await transcribeWav({ wavRel: "line-0.wav", hyperframesDir: dir }), null); + assert.equal(existsSync(marker), false); + assert.equal(errors.length, 1); + } finally { + console.error = originalError; + process.env.PATH = saved.PATH; + if (saved.entry !== undefined) process.env.HYPERFRAMES_CLI_ENTRY = saved.entry; + rmSync(dir, { recursive: true, force: true }); + } }, ); diff --git a/skills/media-use/references/audio.md b/skills/media-use/references/audio.md index 4a4df4ae81e..0d12603f6f2 100644 --- a/skills/media-use/references/audio.md +++ b/skills/media-use/references/audio.md @@ -9,6 +9,8 @@ shot), use the shared engine at `audio/scripts/audio.mjs`. It takes a neutral node /audio/scripts/audio.mjs --request ./audio_request.json --out ./audio_meta.json ``` +Kokoro voices and word timings by transcription run the HyperFrames CLI. Helpers never look it up in the project: run them through the plugin launcher's `--script`, or set `HYPERFRAMES_CLI_ENTRY` to the absolute path of your installed CLI's `bin/hyperframes.mjs`. Without one, those steps stop and say so. + - **Request** `{ provider?, voice?, lang?, speed?, tts_model?, style?, lines: [{ id, text, style?, sfx?: [names] }], bgm: { mode?, query?, prompt? } }`: `id` joins each line back to your model; `bgm.mode` = `retrieve | generate | none` (omit for auto). `--only tts,bgm,sfx` runs a subset and merges into an existing `--out`. - **Your own recording**: a line with `audio` (a path in the project) instead of `text` is used as the voice: `{ id, audio: "take.m4a", words?: [{ text, start, end }] }`. It is converted to `assets/voice/.wav`, `speed` stretches it with the pitch kept and scales `words` the same (without `words` the recording is transcribed). Its `audio_meta.json` voice has `source: "recording"`; with only recording lines `tts_provider` is `"recording"` and no provider or HeyGen sign-in is needed. - **Gemini narration** is opt-in with `provider: "gemini"` and an API key or service-account credentials. `tts_model` defaults to `gemini-3.8-flash-tts`; 3.8 Flash-Lite, 3.1 Flash Preview, and 2.5 Pro/Flash Preview TTS are also supported. `style` directs delivery; a line's style overrides the request's style. CLI overrides: `--tts-model`, `--style`. See `audio/references/tts.md` for a complete request. The existing automatic provider order is unchanged. diff --git a/skills/media-use/references/operations.md b/skills/media-use/references/operations.md index 6248ad19d6e..c0814a06805 100644 --- a/skills/media-use/references/operations.md +++ b/skills/media-use/references/operations.md @@ -126,7 +126,8 @@ npx hyperframes transcribe talk.mp4 --engine parakeet # or --engine auto (defa VERIFIED on 24GB: accurate, ~3s (cached) for 8s audio. Parakeet covers 25 European languages, English included. For other languages, or when parakeet-mlx is not -installed, transcribe.mjs falls back to `hyperframes transcribe`, which uses the +installed, transcribe.mjs falls back to `hyperframes transcribe` (the CLI handed over as +`HYPERFRAMES_CLI_ENTRY`, see `references/audio.md`), which uses the CLI's Parakeet when `models install parakeet` has run and whisper.cpp (99 languages) otherwise. `--engine parakeet|whisper` forces one, except that a `--language` other than English runs whisper whenever it is installed, since diff --git a/skills/media-use/scripts/lib/npx-sync.mjs b/skills/media-use/scripts/lib/npx-sync.mjs index f4ad6f0e94f..ff57660c677 100644 --- a/skills/media-use/scripts/lib/npx-sync.mjs +++ b/skills/media-use/scripts/lib/npx-sync.mjs @@ -24,11 +24,19 @@ function resolveNpxCliPath(env, pathExists) { // so tests can exercise the win32 branch without mocking node:child_process // (its ESM exports are non-configurable) — same idiom as spawnP and // localTtsGenerate. -// A HyperFrames CLI that launches this code hands over its own entry file, so `npx hyperframes` -// runs that same build with the same Node instead of whatever npx resolves from the project. +// The HyperFrames CLI or plugin launcher that starts this code hands over its own entry file, so +// `npx hyperframes` runs that same CLI with the same Node. Without one it fails rather than +// letting npx resolve `hyperframes` from the project. function launchingCliInvocation(argv, opts, env) { + if (argv[0] !== "hyperframes") return null; const entry = env.HYPERFRAMES_CLI_ENTRY; - if (argv[0] !== "hyperframes" || !entry || !isAbsolute(entry)) return null; + if (!entry || !isAbsolute(entry)) { + throw new Error( + "No HyperFrames CLI was handed to this helper, and it does not look one up in the project. " + + "Run the helper through the plugin launcher (`plugin-cli.mjs --script ...`), or set " + + "HYPERFRAMES_CLI_ENTRY to the absolute path of your installed CLI's bin/hyperframes.mjs.", + ); + } return { cmd: process.execPath, args: [entry, ...argv.slice(1).map((arg) => String(arg))], diff --git a/skills/media-use/scripts/resolve-plugin.test.mjs b/skills/media-use/scripts/resolve-plugin.test.mjs index 8460ee812f2..5b6d46d50bd 100644 --- a/skills/media-use/scripts/resolve-plugin.test.mjs +++ b/skills/media-use/scripts/resolve-plugin.test.mjs @@ -1,27 +1,22 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; -test( - "plugin resolver uses the release CLI without a repository checkout", - { skip: process.platform === "win32" }, - (t) => { - const dir = mkdtempSync(join(tmpdir(), "hf-resolve-plugin-")); - t.after(() => rmSync(dir, { recursive: true, force: true })); - mkdirSync(join(dir, "lib")); - for (const file of ["resolve.mjs", "lib/npx-sync.mjs"]) { - copyFileSync(fileURLToPath(new URL(file, import.meta.url)), join(dir, file)); - } - writeFileSync( - join(dir, "npx"), - `#!${process.execPath}\nconsole.log(JSON.stringify({args:process.argv.slice(2),skip:process.env.HYPERFRAMES_SKIP_SKILLS}));process.exit(7);`, - { mode: 0o755 }, - ); - const result = spawnSync(process.execPath, [join(dir, "resolve.mjs"), "--intent", "a & b"], { +function pluginShim(t) { + const dir = mkdtempSync(join(tmpdir(), "hf-resolve-plugin-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + mkdirSync(join(dir, "lib")); + for (const file of ["resolve.mjs", "lib/npx-sync.mjs"]) { + copyFileSync(fileURLToPath(new URL(file, import.meta.url)), join(dir, file)); + } + const marker = join(dir, "npx-ran"); + writeFileSync(join(dir, "npx"), `#!/bin/sh\ntouch "${marker}"\nexit 9\n`, { mode: 0o755 }); + const run = (env) => + spawnSync(process.execPath, [join(dir, "resolve.mjs"), "--intent", "a & b"], { cwd: dir, encoding: "utf8", env: { @@ -29,13 +24,42 @@ test( PATH: `${dir}${delimiter}${process.env.PATH}`, HYPERFRAMES_PLUGIN_VERSION: "1.2.3", HYPERFRAMES_SKIP_SKILLS: "1", + HYPERFRAMES_CLI_ENTRY: "", + ...env, }, }); + return { dir, marker, run }; +} + +test( + "plugin resolver runs the CLI the launcher handed over, not npx in the project", + { skip: process.platform === "win32" }, + (t) => { + const { dir, marker, run } = pluginShim(t); + const launcher = join(dir, "launcher.mjs"); + writeFileSync( + launcher, + "console.log(JSON.stringify({args:process.argv.slice(2),skip:process.env.HYPERFRAMES_SKIP_SKILLS}));process.exit(7);", + ); + const result = run({ HYPERFRAMES_CLI_ENTRY: launcher }); assert.equal(result.status, 7, result.stderr); assert.deepEqual(JSON.parse(result.stdout), { - args: ["--yes", "hyperframes@1.2.3", "media-use", "resolve", "--intent", "a & b"], + args: ["media-use", "resolve", "--intent", "a & b"], skip: "1", }); + assert.equal(existsSync(marker), false); + }, +); + +test( + "plugin resolver without a handed-over CLI fails with the fix", + { skip: process.platform === "win32" }, + (t) => { + const { marker, run } = pluginShim(t); + const result = run({}); + assert.equal(result.status, 1); + assert.match(result.stderr, /plugin-cli\.mjs --script/); + assert.equal(existsSync(marker), false); }, ); diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index a2a0b9ba163..02dfac66c87 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -5,17 +5,21 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveNpxInvocation } from "./lib/npx-sync.mjs"; -// Installed plugins have no monorepo packages/ tree. The plugin launcher passes -// the release version and suppression environment through to this legacy shim. -const pluginVersion = process.env.HYPERFRAMES_PLUGIN_VERSION; -if (pluginVersion) { - const invocation = resolveNpxInvocation( - ["--yes", `hyperframes@${pluginVersion}`, "media-use", "resolve", ...process.argv.slice(2)], - { stdio: "inherit" }, - ); - const result = spawnSync(invocation.cmd, invocation.args, invocation.opts); - if (result.error) console.error(result.error.message); - process.exit(result.status ?? 1); +// Installed plugins have no monorepo packages/ tree. The plugin launcher passes the +// release version and hands itself over as the CLI, so this legacy shim runs the pinned CLI. +if (process.env.HYPERFRAMES_PLUGIN_VERSION) { + try { + const invocation = resolveNpxInvocation( + ["hyperframes", "media-use", "resolve", ...process.argv.slice(2)], + { stdio: "inherit" }, + ); + const result = spawnSync(invocation.cmd, invocation.args, invocation.opts); + if (result.error) console.error(result.error.message); + process.exit(result.status ?? 1); + } catch (error) { + console.error(error.message); + process.exit(1); + } } const root = join(fileURLToPath(new URL("../../../", import.meta.url))); const dist = join(root, "packages/cli/dist/cli.js"); diff --git a/skills/media-use/scripts/transcribe.test.mjs b/skills/media-use/scripts/transcribe.test.mjs index 3f0d2a53b11..30b34e9b21f 100644 --- a/skills/media-use/scripts/transcribe.test.mjs +++ b/skills/media-use/scripts/transcribe.test.mjs @@ -1,29 +1,38 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -// The stub stands in for `npx hyperframes transcribe --dir ...`: it records its args, -// writes the CLI's flat array and prints the CLI's --json line naming `cliEngine`. +// The stub stands in for the `hyperframes` CLI the caller hands over in HYPERFRAMES_CLI_ENTRY, +// run as `transcribe --dir ...`: it records its args, writes the CLI's flat array and +// prints the CLI's --json line naming `cliEngine`. function runScript(engine, cliEngine) { const root = mkdtempSync(join(tmpdir(), "media-use-transcribe-")); try { - const bin = join(root, "bin"); - mkdirSync(bin); + const cli = join(root, "cli.mjs"); writeFileSync( - join(bin, "npx"), - `#!/bin/sh\necho "$@" > "${root}/args"\nwhile [ "$1" != "--dir" ]; do shift; done\n` + - `echo '[{"text":"a","start":0,"end":1},{"text":"b","start":1,"end":2}]' > "$2/transcript.json"\n` + - `echo '{"ok":true,"engine":"${cliEngine}"}'\n`, + cli, + `import { writeFileSync } from "node:fs";\nconst a = process.argv.slice(2);\n` + + `writeFileSync(${JSON.stringify(join(root, "args"))}, a.join(" "));\n` + + `writeFileSync(a[a.indexOf("--dir") + 1] + "/transcript.json", ` + + `'[{"text":"a","start":0,"end":1},{"text":"b","start":1,"end":2}]');\n` + + `console.log('{"ok":true,"engine":"${cliEngine}"}');\n`, ); - chmodSync(join(bin, "npx"), 0o755); const input = join(root, "in.wav"); writeFileSync(input, ""); const script = fileURLToPath(new URL("./transcribe.mjs", import.meta.url)); - const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, HOME: root }; + const env = { ...process.env, HYPERFRAMES_CLI_ENTRY: cli, HOME: root }; delete env.HYPERFRAMES_PARAKEET; const out = execFileSync( process.execPath, @@ -80,11 +89,8 @@ test( mkdirSync(bin); const reason = "Parakeet is not installed. Install it with: hyperframes models install parakeet"; - writeFileSync( - join(bin, "npx"), - `#!/bin/sh\necho '{"ok":false,"error":"${reason}"}'\nexit 1\n`, - ); - chmodSync(join(bin, "npx"), 0o755); + const cli = join(bin, "cli.mjs"); + writeFileSync(cli, `console.log('{"ok":false,"error":"${reason}"}');\nprocess.exit(1);\n`); const input = join(root, "in.wav"); writeFileSync(input, ""); const script = fileURLToPath(new URL("./transcribe.mjs", import.meta.url)); @@ -96,7 +102,7 @@ test( env: { ...process.env, HYPERFRAMES_PARAKEET: "", - PATH: `${bin}:${process.env.PATH}`, + HYPERFRAMES_CLI_ENTRY: cli, HOME: root, HYPERFRAMES_MEDIA_HOME: join(root, "home"), HYPERFRAMES_NO_TELEMETRY: "1", @@ -110,3 +116,40 @@ test( } }, ); + +test( + "without a handed-over CLI it fails with the fix instead of running npx in the project", + { skip: process.platform === "win32" }, + () => { + const root = mkdtempSync(join(tmpdir(), "media-use-transcribe-")); + try { + const bin = join(root, "bin"); + mkdirSync(bin); + const marker = join(root, "npx-ran"); + writeFileSync(join(bin, "npx"), `#!/bin/sh\ntouch "${marker}"\n`); + chmodSync(join(bin, "npx"), 0o755); + const input = join(root, "in.wav"); + writeFileSync(input, ""); + const script = fileURLToPath(new URL("./transcribe.mjs", import.meta.url)); + const env = { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + HOME: root, + HYPERFRAMES_MEDIA_HOME: join(root, "home"), + HYPERFRAMES_NO_TELEMETRY: "1", + }; + delete env.HYPERFRAMES_PARAKEET; + delete env.HYPERFRAMES_CLI_ENTRY; + const res = spawnSync( + process.execPath, + [script, "--input", input, "--engine", "whisper", "--json"], + { cwd: root, encoding: "utf8", env }, + ); + assert.equal(res.status, 1); + assert.match(JSON.parse(res.stdout.trim()).error, /HYPERFRAMES_CLI_ENTRY/); + assert.equal(existsSync(marker), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, +); From 91349d310416d0240a9f169f18306a5f639b38ed Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Nov 2026 10:48:12 +0000 Subject: [PATCH 2/3] fix(skills): keep the project name and require a home folder for npx The CLI bin now sets PWD to the project folder after moving into it, because npm's shell resets PWD to the folder npx started in and preview names the project from PWD. The plugin launcher no longer falls back to the temp folder when there is no home folder; it stops with an error instead, and only when it is about to start npx. Co-Authored-By: Claude Opus 5.5 --- packages/cli/bin/hyperframes.mjs | 6 +++++- packages/cli/src/binEntry.test.ts | 15 +++++++++------ skills-manifest.json | 2 +- skills/hyperframes/scripts/plugin-cli.mjs | 17 ++++++++++------- skills/hyperframes/scripts/plugin-cli.test.mjs | 17 ++++++++++++++++- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/packages/cli/bin/hyperframes.mjs b/packages/cli/bin/hyperframes.mjs index a426d2d4c35..aae934a7f1d 100644 --- a/packages/cli/bin/hyperframes.mjs +++ b/packages/cli/bin/hyperframes.mjs @@ -7,7 +7,11 @@ import { runtimeVersionError } from "../dist/runtimeVersion.js"; // and names the project here. Nested CLI runs keep the working folder their caller gives them. const projectDir = process.env.HYPERFRAMES_PLUGIN_CWD; delete process.env.HYPERFRAMES_PLUGIN_CWD; -if (projectDir) process.chdir(projectDir); +if (projectDir) { + process.chdir(projectDir); + // npm's shell resets PWD to the folder npx started in; commands read the project name from it. + process.env.PWD = projectDir; +} const error = runtimeVersionError(process.versions.node); if (error) { diff --git a/packages/cli/src/binEntry.test.ts b/packages/cli/src/binEntry.test.ts index a039a81543e..a8f27f1ce15 100644 --- a/packages/cli/src/binEntry.test.ts +++ b/packages/cli/src/binEntry.test.ts @@ -26,9 +26,10 @@ function runBin(handover: (project: string) => string | undefined) { ); writeFileSync( join(root, "pkg/dist/cli.js"), - "console.log(JSON.stringify({ cwd: process.cwd(), passedOn: process.env.HYPERFRAMES_PLUGIN_CWD ?? null }));\n", + "console.log(JSON.stringify({ cwd: process.cwd(), pwd: process.env.PWD, passedOn: process.env.HYPERFRAMES_PLUGIN_CWD ?? null }));\n", ); - const env = { ...process.env }; + // npm's shell sets PWD to the folder npx started in. + const env = { ...process.env, PWD: outside }; delete env.HYPERFRAMES_PLUGIN_CWD; const named = handover(project); if (named !== undefined) env.HYPERFRAMES_PLUGIN_CWD = named; @@ -41,18 +42,20 @@ function runBin(handover: (project: string) => string | undefined) { return { seen: JSON.parse(result.stdout), project: realpathSync(project), + projectNamed: project, outside: realpathSync(outside), + outsideNamed: outside, }; } describe("bin/hyperframes.mjs", () => { it("runs in the project the plugin launcher names, and does not pass that on", () => { - const { seen, project } = runBin((dir) => dir); - expect(seen).toEqual({ cwd: project, passedOn: null }); + const { seen, project, projectNamed } = runBin((dir) => dir); + expect(seen).toEqual({ cwd: project, pwd: projectNamed, passedOn: null }); }); it("stays in its working folder without a launcher", () => { - const { seen, outside } = runBin(() => undefined); - expect(seen).toEqual({ cwd: outside, passedOn: null }); + const { seen, outside, outsideNamed } = runBin(() => undefined); + expect(seen).toEqual({ cwd: outside, pwd: outsideNamed, passedOn: null }); }); }); diff --git a/skills-manifest.json b/skills-manifest.json index 23ee47b0a5a..2e8d8c95824 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 2 }, "hyperframes": { - "hash": "5255c748a61c5fcd", + "hash": "6792f71d4cb1e812", "files": 78 }, "hyperframes-cli": { diff --git a/skills/hyperframes/scripts/plugin-cli.mjs b/skills/hyperframes/scripts/plugin-cli.mjs index 4aaff183556..3fe92d47b15 100644 --- a/skills/hyperframes/scripts/plugin-cli.mjs +++ b/skills/hyperframes/scripts/plugin-cli.mjs @@ -2,7 +2,7 @@ // Run from the user's project; locate the release from this installed file. import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; +import { homedir } from "node:os"; import { join, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { isMainModule } from "./lib/main-module.mjs"; @@ -35,9 +35,11 @@ export function pluginVersion(pluginRoot = root) { } // npm takes the package, bin links and .npmrc from the folder it starts in, so npx starts -// in the home folder and the CLI moves into the project before it runs a command. -function npmFolder(home = homedir(), pathExists = existsSync) { - return home && pathExists(home) ? home : tmpdir(); +// in the home folder and the CLI moves into the project before it runs a command. A shared +// folder such as the temp folder is no substitute, so there is no fallback. +export function npmFolder(home = homedir(), pathExists = existsSync) { + if (home && pathExists(home)) return home; + throw new Error("Cannot find your home folder. Set HOME (USERPROFILE on Windows) and retry."); } export function invocation( @@ -49,7 +51,7 @@ export function invocation( node = process.execPath, pathExists = existsSync, cwd = process.cwd(), - npmCwd = npmFolder(), + npmCwd, launcher = fileURLToPath(import.meta.url), } = {}, ) { @@ -72,7 +74,8 @@ export function invocation( } const cliArgs = ["--yes", `hyperframes@${version}`, ...args]; const cliEnv = { ...childEnv, HYPERFRAMES_PLUGIN_CWD: cwd }; - if (platform !== "win32") return { command: "npx", args: cliArgs, env: cliEnv, cwd: npmCwd }; + const npxCwd = npmCwd ?? npmFolder(); + if (platform !== "win32") return { command: "npx", args: cliArgs, env: cliEnv, cwd: npxCwd }; const candidates = [ env.npm_execpath && win32.join(win32.dirname(env.npm_execpath), "npx-cli.js"), win32.join(win32.dirname(node), "node_modules", "npm", "bin", "npx-cli.js"), @@ -82,7 +85,7 @@ export function invocation( throw new Error( "Cannot find npx-cli.js. Install Node.js with npm or run from an npm environment.", ); - return { command: node, args: [npx, ...cliArgs], env: cliEnv, cwd: npmCwd }; + return { command: node, args: [npx, ...cliArgs], env: cliEnv, cwd: npxCwd }; } if (isMainModule(import.meta.url)) { diff --git a/skills/hyperframes/scripts/plugin-cli.test.mjs b/skills/hyperframes/scripts/plugin-cli.test.mjs index 825168d7321..190ab8b7001 100644 --- a/skills/hyperframes/scripts/plugin-cli.test.mjs +++ b/skills/hyperframes/scripts/plugin-cli.test.mjs @@ -12,7 +12,7 @@ import { import { delimiter, join } from "node:path"; import { tmpdir } from "node:os"; import { test } from "node:test"; -import { invocation, pluginVersion } from "./plugin-cli.mjs"; +import { invocation, npmFolder, pluginVersion } from "./plugin-cli.mjs"; function fixture(t, manifest = "plugin.json", version = "1.2.3") { const root = mkdtempSync(join(tmpdir(), "hf-plugin-")); @@ -162,3 +162,18 @@ test( }); }, ); + +test("starts npx in the home folder and never falls back to a shared folder", () => { + assert.equal( + npmFolder("/home/person", () => true), + "/home/person", + ); + assert.throws(() => npmFolder("", () => true), /Cannot find your home folder/); + assert.throws(() => npmFolder("/home/gone", () => false), /Cannot find your home folder/); + // Helper scripts never start npx, so they run without a home folder. + const script = invocation(["--script", "helper.mjs"], { + version: "1.2.3", + cwd: "/project", + }); + assert.equal(script.cwd, "/project"); +}); From a7e4742b3ba6cc8a9418aa878c5985f3201f3eb5 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 11 Nov 2026 11:08:18 +0000 Subject: [PATCH 3/3] test(cli): type the bin test environment Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/binEntry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/binEntry.test.ts b/packages/cli/src/binEntry.test.ts index a8f27f1ce15..24147fcdd16 100644 --- a/packages/cli/src/binEntry.test.ts +++ b/packages/cli/src/binEntry.test.ts @@ -29,7 +29,7 @@ function runBin(handover: (project: string) => string | undefined) { "console.log(JSON.stringify({ cwd: process.cwd(), pwd: process.env.PWD, passedOn: process.env.HYPERFRAMES_PLUGIN_CWD ?? null }));\n", ); // npm's shell sets PWD to the folder npx started in. - const env = { ...process.env, PWD: outside }; + const env: NodeJS.ProcessEnv = { ...process.env, PWD: outside }; delete env.HYPERFRAMES_PLUGIN_CWD; const named = handover(project); if (named !== undefined) env.HYPERFRAMES_PLUGIN_CWD = named;