Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/cli/bin/hyperframes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/binEntry.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
53 changes: 22 additions & 31 deletions packages/cli/src/media-use/lib/npx-sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/,
);
}
});
27 changes: 16 additions & 11 deletions packages/cli/src/media-use/lib/tts-local-provider.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
112 changes: 34 additions & 78 deletions packages/cli/src/media-use/lib/tts-local-provider.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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/);
}
}
});
4 changes: 2 additions & 2 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"files": 2
},
"hyperframes": {
"hash": "3abc3f132de58c47",
"hash": "6792f71d4cb1e812",
"files": 78
},
"hyperframes-cli": {
Expand All @@ -18,7 +18,7 @@
"files": 13
},
"media-use": {
"hash": "c72fc9eddec1b5ed",
"hash": "25f93e8726681a5b",
"files": 122
},
"remotion-to-hyperframes": {
Expand Down
7 changes: 5 additions & 2 deletions skills/hyperframes/references/plugin-installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <absolute-script-path> <args...>`. 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
Expand Down
Loading
Loading