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.
This commit is contained in:
Miguel Ángel
2026-07-07 18:40:43 -04:00
committed by GitHub
parent 4a36655b2b
commit 4b3c73d941
8 changed files with 469 additions and 62 deletions
@@ -123,3 +123,36 @@ describe("detectInstaller", () => {
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();
});
});