diff --git a/packages/cli/bin/hyperframes.mjs b/packages/cli/bin/hyperframes.mjs index e8fa42661e3..aae934a7f1d 100644 --- a/packages/cli/bin/hyperframes.mjs +++ b/packages/cli/bin/hyperframes.mjs @@ -3,6 +3,16 @@ 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); + // 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) { 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..24147fcdd16 --- /dev/null +++ b/packages/cli/src/binEntry.test.ts @@ -0,0 +1,61 @@ +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(), 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: NodeJS.ProcessEnv = { ...process.env, PWD: outside }; + 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), + 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, 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, outsideNamed } = runBin(() => undefined); + expect(seen).toEqual({ cwd: outside, pwd: outsideNamed, 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..2e8d8c95824 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -6,7 +6,7 @@ "files": 2 }, "hyperframes": { - "hash": "3abc3f132de58c47", + "hash": "6792f71d4cb1e812", "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..3fe92d47b15 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 } from "node:os"; import { join, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import { isMainModule } from "./lib/main-module.mjs"; @@ -33,6 +34,14 @@ 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. 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( args, { @@ -41,6 +50,9 @@ export function invocation( platform = process.platform, node = process.execPath, pathExists = existsSync, + cwd = process.cwd(), + npmCwd, + launcher = fileURLToPath(import.meta.url), } = {}, ) { if (args[0] === "skills") @@ -56,10 +68,14 @@ 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 }; + 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"), @@ -69,13 +85,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: npxCwd }; } 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..190ab8b7001 100644 --- a/skills/hyperframes/scripts/plugin-cli.test.mjs +++ b/skills/hyperframes/scripts/plugin-cli.test.mjs @@ -9,10 +9,10 @@ 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"; +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-")); @@ -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,58 @@ 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), + }); + }, +); + +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"); +}); 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 }); + } + }, +);