mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
* fix(cli): upgrade + update-notice use the detected install method
hyperframes upgrade hardcoded 'npm install -g', so bun/pnpm/brew users either
saw it fail or silently got a shadowed npm copy while their real (older) binary
kept running. Route the install through detectInstaller() via a new
installInvocation() argv helper; for skip kinds (ephemeral npx/bunx,
project-local, unknown) print 'npx hyperframes@latest' instead of guessing.
The passive update notice now shows the detected manager's command too. Semver
safety guard consolidated into a shared isSafeVersion(). Suppression gates and
the background auto-update flow are unchanged.
* test(cli): pin the shell:false contract of the --yes install path
Export runDetectedInstall and add a mocked-execFileSync test asserting the
detected manager binary is spawned with the exact installInvocation argv,
{stdio:inherit, shell:false}, and that an install failure sets a non-zero exit
code without throwing. Addresses review nit on the untested --yes path.
* fix(cli): guard the registry version at the boundary; execFile the auto-installer
Security (addresses review): a poisoned registry data.version (e.g.
'1.2.3; rm -rf /') was cached unvalidated and flowed into the background
auto-updater, which ran it via exec() -- a shell -- so a registry compromise
meant RCE on the next CLI run. isSafeVersion only covered the two touched
consumers (upgrade, notice), not this third sibling (scheduleBackgroundInstall).
- Guard at the registry boundary in checkForUpdate: only a strict-semver STRING
is trusted; a non-string or metachar-bearing data.version is never cached and
falls back to the last known-good version. The cache-read and fallback paths
re-validate too, so a pre-existing poisoned cache can't leak through. One gate
closes all three consumers and any future one; per-consumer checks stay as
defense in depth.
- The detached auto-installer now runs via execFile(bin, args, shell:false),
reusing installInvocation, matching the interactive runDetectedInstall path --
the shell is gone from that path entirely.
Tests: reject poisoned / non-string registry version (never cached); accept a
valid semver.
58 lines
1.9 KiB
TypeScript
58 lines
1.9 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
/**
|
|
* Pins the security-relevant contract of the `--yes` install path: the detected
|
|
* manager binary is spawned via execFileSync with `shell: false` and the exact
|
|
* argv from installInvocation — no shell, so a version can never be re-parsed
|
|
* as shell syntax. installInvocation's argv correctness is covered separately
|
|
* in installerDetection.test.ts; this locks how it's executed.
|
|
*/
|
|
describe("runDetectedInstall", () => {
|
|
afterEach(() => {
|
|
vi.doUnmock("node:child_process");
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("spawns the manager binary with shell:false and inherited stdio", async () => {
|
|
const execSpy = vi.fn();
|
|
vi.resetModules();
|
|
vi.doMock("node:child_process", () => ({ execFileSync: execSpy }));
|
|
|
|
const { runDetectedInstall } = await import("./upgrade.js");
|
|
runDetectedInstall(
|
|
{ bin: "bun", args: ["add", "-g", "hyperframes@1.2.3"] },
|
|
"bun add -g hyperframes@1.2.3",
|
|
"1.2.3",
|
|
);
|
|
|
|
expect(execSpy).toHaveBeenCalledTimes(1);
|
|
expect(execSpy).toHaveBeenCalledWith("bun", ["add", "-g", "hyperframes@1.2.3"], {
|
|
stdio: "inherit",
|
|
shell: false,
|
|
});
|
|
});
|
|
|
|
it("sets a non-zero exit code when the install fails, without throwing", async () => {
|
|
const execSpy = vi.fn(() => {
|
|
throw new Error("install boom");
|
|
});
|
|
vi.resetModules();
|
|
vi.doMock("node:child_process", () => ({ execFileSync: execSpy }));
|
|
|
|
const { runDetectedInstall } = await import("./upgrade.js");
|
|
const original = process.exitCode;
|
|
try {
|
|
expect(() =>
|
|
runDetectedInstall(
|
|
{ bin: "npm", args: ["install", "-g", "hyperframes@1.2.3"] },
|
|
"npm install -g hyperframes@1.2.3",
|
|
"1.2.3",
|
|
),
|
|
).not.toThrow();
|
|
expect(process.exitCode).toBe(1);
|
|
} finally {
|
|
process.exitCode = original;
|
|
}
|
|
});
|
|
});
|