mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +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.
159 lines
5.5 KiB
TypeScript
159 lines
5.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// The module inspects `process.argv[1]` + `realpathSync`. We stub both so
|
|
// each test describes a hypothetical install layout without touching the
|
|
// filesystem.
|
|
|
|
type InstallerInfo =
|
|
(typeof import("./installerDetection.js"))["detectInstaller"] extends () => infer R ? R : never;
|
|
|
|
async function detectWith(realPath: string | null): Promise<InstallerInfo> {
|
|
vi.resetModules();
|
|
const origArgv1 = process.argv[1];
|
|
if (realPath === null) {
|
|
process.argv[1] = "";
|
|
} else {
|
|
// argv[1] doesn't matter — realpathSync is what gets checked after the
|
|
// resolver runs. Set it to the unresolved form and stub fs.realpathSync
|
|
// to return the scenario's resolved path.
|
|
process.argv[1] = "/not/used/at/runtime";
|
|
}
|
|
vi.doMock("node:fs", async () => {
|
|
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
|
return {
|
|
...actual,
|
|
realpathSync:
|
|
realPath === null
|
|
? () => {
|
|
throw new Error("simulated unresolved path");
|
|
}
|
|
: () => realPath,
|
|
};
|
|
});
|
|
|
|
const mod = await import("./installerDetection.js");
|
|
const info = mod.detectInstaller();
|
|
process.argv[1] = origArgv1 ?? "";
|
|
return info;
|
|
}
|
|
|
|
describe("detectInstaller", () => {
|
|
afterEach(() => {
|
|
vi.doUnmock("node:fs");
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("classifies workspace link as skip (monorepo dev)", async () => {
|
|
const info = await detectWith("/Users/dev/hyperframes-oss/packages/cli/dist/cli.js");
|
|
expect(info.kind).toBe("skip");
|
|
expect(info.reason).toContain("workspace");
|
|
expect(info.installCommand("0.4.4")).toBeNull();
|
|
});
|
|
|
|
it("classifies npx _npx cache path as skip", async () => {
|
|
const info = await detectWith(
|
|
"/Users/me/.npm/_npx/abc123/node_modules/hyperframes/dist/cli.js",
|
|
);
|
|
expect(info.kind).toBe("skip");
|
|
expect(info.reason.toLowerCase()).toContain("ephemeral");
|
|
});
|
|
|
|
it("classifies bunx temp dir as skip", async () => {
|
|
const info = await detectWith("/var/folders/tmp/bunx-501-hyperframes/entry.js");
|
|
expect(info.kind).toBe("skip");
|
|
expect(info.reason.toLowerCase()).toContain("ephemeral");
|
|
});
|
|
|
|
it("detects Homebrew install", async () => {
|
|
const info = await detectWith("/opt/homebrew/Cellar/hyperframes/0.4.3/bin/hyperframes");
|
|
expect(info.kind).toBe("brew");
|
|
expect(info.installCommand("0.4.4")).toBe("brew upgrade hyperframes");
|
|
});
|
|
|
|
it("detects bun global install", async () => {
|
|
const info = await detectWith(
|
|
"/Users/me/.bun/install/global/node_modules/hyperframes/dist/cli.js",
|
|
);
|
|
expect(info.kind).toBe("bun");
|
|
expect(info.installCommand("0.4.4")).toBe("bun add -g hyperframes@0.4.4");
|
|
});
|
|
|
|
it("detects pnpm global install (Library/pnpm path)", async () => {
|
|
const info = await detectWith(
|
|
"/Users/me/Library/pnpm/global/5/node_modules/hyperframes/dist/cli.js",
|
|
);
|
|
expect(info.kind).toBe("pnpm");
|
|
expect(info.installCommand("0.4.4")).toBe("pnpm add -g hyperframes@0.4.4");
|
|
});
|
|
|
|
it("treats pnpm project-local installs as unknown layouts", async () => {
|
|
const info = await detectWith(
|
|
"/path/to/project/node_modules/.pnpm/hyperframes@0.4.3/node_modules/hyperframes/dist/cli.js",
|
|
);
|
|
expect(info.kind).toBe("skip");
|
|
expect(info.installCommand("0.4.4")).toBeNull();
|
|
});
|
|
|
|
it("detects npm global install", async () => {
|
|
const info = await detectWith("/usr/local/lib/node_modules/hyperframes/dist/cli.js");
|
|
expect(info.kind).toBe("npm");
|
|
expect(info.installCommand("0.4.4")).toBe("npm install -g hyperframes@0.4.4");
|
|
});
|
|
|
|
it("detects npm global install on Windows", async () => {
|
|
const info = await detectWith(
|
|
"C:\\Users\\me\\AppData\\Roaming\\npm\\node_modules\\hyperframes\\dist\\cli.js",
|
|
);
|
|
expect(info.kind).toBe("npm");
|
|
expect(info.installCommand("0.4.4")).toBe("npm install -g hyperframes@0.4.4");
|
|
});
|
|
|
|
it("returns skip when the entry cannot be resolved", async () => {
|
|
const info = await detectWith(null);
|
|
// realpathSync throws → reason is "could not resolve" OR the path itself
|
|
// (the fallback returns the unresolved argv[1]); either way the kind is
|
|
// skip-or-unknown which we treat as skip downstream.
|
|
expect(info.kind).toBe("skip");
|
|
expect(info.installCommand("0.4.4")).toBeNull();
|
|
});
|
|
|
|
it("returns skip for an unknown install layout", async () => {
|
|
const info = await detectWith("/some/random/path/hyperframes");
|
|
expect(info.kind).toBe("skip");
|
|
expect(info.reason).toMatch(/Unknown install layout/);
|
|
});
|
|
});
|
|
|
|
import { installInvocation } from "./installerDetection.js";
|
|
|
|
describe("installInvocation", () => {
|
|
it("returns the npm global argv for kind npm", () => {
|
|
expect(installInvocation("npm", "1.2.3")).toEqual({
|
|
bin: "npm",
|
|
args: ["install", "-g", "hyperframes@1.2.3"],
|
|
});
|
|
});
|
|
|
|
it("returns bun/pnpm add -g argv for those managers", () => {
|
|
expect(installInvocation("bun", "1.2.3")).toEqual({
|
|
bin: "bun",
|
|
args: ["add", "-g", "hyperframes@1.2.3"],
|
|
});
|
|
expect(installInvocation("pnpm", "1.2.3")).toEqual({
|
|
bin: "pnpm",
|
|
args: ["add", "-g", "hyperframes@1.2.3"],
|
|
});
|
|
});
|
|
|
|
it("returns a version-less brew upgrade for kind brew", () => {
|
|
expect(installInvocation("brew", "1.2.3")).toEqual({
|
|
bin: "brew",
|
|
args: ["upgrade", "hyperframes"],
|
|
});
|
|
});
|
|
|
|
it("returns null for kind skip (ephemeral / project-local / unknown)", () => {
|
|
expect(installInvocation("skip", "1.2.3")).toBeNull();
|
|
});
|
|
});
|