Files
hyperframes/packages/cli/src/utils/installerDetection.test.ts
T
Miguel Ángel 4b3c73d941 fix(cli): upgrade and update notice use the detected install method
* 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.
2026-07-07 18:40:43 -04:00

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();
});
});