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.
190 lines
6.4 KiB
TypeScript
190 lines
6.4 KiB
TypeScript
/**
|
|
* Detect how the running `hyperframes` binary was installed so auto-update can
|
|
* re-use the same installer. Getting this wrong means either silently failing
|
|
* to update or clobbering a Homebrew install with npm, so the classifier is
|
|
* deliberately conservative — when unsure we return `skip` and leave the user
|
|
* in charge.
|
|
*/
|
|
|
|
import { realpathSync } from "node:fs";
|
|
import { posix } from "node:path";
|
|
|
|
export type InstallerKind = "npm" | "bun" | "pnpm" | "brew" | "skip";
|
|
|
|
export interface InstallerInfo {
|
|
kind: InstallerKind;
|
|
/** Full command to install the given version, or null when `kind === "skip"`. */
|
|
installCommand: (version: string) => string | null;
|
|
/** Human-readable reason for debug logging / doctor output. */
|
|
reason: string;
|
|
}
|
|
|
|
/**
|
|
* `process.argv[1]` points at the CLI entry script but on global installs the
|
|
* entry is usually a shim in a `bin/` dir that symlinks to the real install
|
|
* under `lib/node_modules/`. Resolve through the symlink so the classifier
|
|
* sees the canonical install prefix.
|
|
*/
|
|
function resolveEntry(): string | null {
|
|
const entry = process.argv[1];
|
|
if (!entry) return null;
|
|
try {
|
|
return realpathSync(entry);
|
|
} catch {
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
function normalizePath(path: string): string {
|
|
return path.replaceAll("\\", "/");
|
|
}
|
|
|
|
/** True when running from a monorepo workspace link (pnpm/bun/yarn `dev:link`). */
|
|
function isWorkspaceLink(realEntry: string): boolean {
|
|
const normalized = normalizePath(realEntry);
|
|
// Resolved path lands inside the repo, typically .../packages/cli/...
|
|
// A real global install never contains `/packages/` because npm publish
|
|
// collapses the package into a flat tarball.
|
|
return normalized.includes("/packages/cli/");
|
|
}
|
|
|
|
/**
|
|
* True when invoked via `npx hyperframes` / `bunx hyperframes`. These don't
|
|
* persist an install, so auto-update is a no-op — the user gets the latest
|
|
* version on the next invocation anyway.
|
|
*/
|
|
function isEphemeralExec(realEntry: string): boolean {
|
|
const normalized = normalizePath(realEntry);
|
|
// npm's npx caches into `<prefix>/_npx/<hash>/`; bun uses `bunx-<uid>-…`.
|
|
return (
|
|
normalized.includes("/_npx/") ||
|
|
normalized.includes("/.npm/_npx/") ||
|
|
posix.basename(posix.dirname(normalized)).startsWith("bunx-")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* True when the binary was linked into Homebrew's install tree. Homebrew
|
|
* symlinks `/opt/homebrew/bin/hyperframes` into `…/Cellar/hyperframes/<v>/…`
|
|
* (or `/usr/local/Cellar/` on Intel). Either path wins the match.
|
|
*/
|
|
function isHomebrewInstall(realEntry: string): boolean {
|
|
return normalizePath(realEntry).includes("/Cellar/hyperframes/");
|
|
}
|
|
|
|
/**
|
|
* Classify the install by walking the resolved entry path against each
|
|
* package manager's well-known global prefix signature.
|
|
*/
|
|
export function detectInstaller(): InstallerInfo {
|
|
const realEntry = resolveEntry();
|
|
if (!realEntry) {
|
|
return {
|
|
kind: "skip",
|
|
installCommand: () => null,
|
|
reason: "Could not resolve process entry path",
|
|
};
|
|
}
|
|
|
|
const normalizedEntry = normalizePath(realEntry);
|
|
|
|
if (isWorkspaceLink(realEntry)) {
|
|
return {
|
|
kind: "skip",
|
|
installCommand: () => null,
|
|
reason: "Running from a workspace link (monorepo dev)",
|
|
};
|
|
}
|
|
|
|
if (isEphemeralExec(realEntry)) {
|
|
return {
|
|
kind: "skip",
|
|
installCommand: () => null,
|
|
reason: "Running via ephemeral exec (npx / bunx)",
|
|
};
|
|
}
|
|
|
|
if (isHomebrewInstall(realEntry)) {
|
|
return {
|
|
kind: "brew",
|
|
// Updating a brew formula isn't a straight `install`; the formula needs
|
|
// to have been published. Defer to `brew upgrade` which is a no-op if
|
|
// the tap hasn't caught up.
|
|
installCommand: () => "brew upgrade hyperframes",
|
|
reason: `Homebrew install detected at ${realEntry}`,
|
|
};
|
|
}
|
|
|
|
// bun's global install prefix is `~/.bun/install/global/node_modules/` and
|
|
// the bin shim lives at `~/.bun/bin/`. Both paths contain `.bun`.
|
|
if (normalizedEntry.includes("/.bun/")) {
|
|
return {
|
|
kind: "bun",
|
|
installCommand: (version) => `bun add -g hyperframes@${version}`,
|
|
reason: `bun global install detected at ${realEntry}`,
|
|
};
|
|
}
|
|
|
|
// pnpm's global prefix is typically `~/Library/pnpm/global/5/node_modules/`
|
|
// on macOS or `~/.local/share/pnpm/global/…` on Linux. `pnpm` wins when the
|
|
// path contains `/pnpm/global/` regardless of platform.
|
|
if (normalizedEntry.includes("/pnpm/global/")) {
|
|
return {
|
|
kind: "pnpm",
|
|
installCommand: (version) => `pnpm add -g hyperframes@${version}`,
|
|
reason: `pnpm global install detected at ${realEntry}`,
|
|
};
|
|
}
|
|
|
|
// npm's default global prefix is `<prefix>/lib/node_modules/hyperframes/…`
|
|
// where `<prefix>` is `/usr/local` (macOS Intel), `/opt/homebrew` (Apple
|
|
// Silicon, non-brew-formula npm), or a user-configured directory.
|
|
if (
|
|
normalizedEntry.includes("/lib/node_modules/hyperframes/") ||
|
|
normalizedEntry.includes("/npm/node_modules/hyperframes/")
|
|
) {
|
|
return {
|
|
kind: "npm",
|
|
installCommand: (version) => `npm install -g hyperframes@${version}`,
|
|
reason: `npm global install detected at ${realEntry}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: "skip",
|
|
installCommand: () => null,
|
|
reason: `Unknown install layout at ${realEntry}`,
|
|
};
|
|
}
|
|
|
|
/** Argv-shaped install command for a no-shell `execFile`. */
|
|
export interface InstallInvocation {
|
|
bin: string;
|
|
args: string[];
|
|
}
|
|
|
|
/**
|
|
* The argv form of {@link InstallerInfo.installCommand}, kept next to the
|
|
* detector so the command we *run* (execFile, no shell) and the command we
|
|
* *display* (installCommand string) can never drift. Returns `null` for `skip`
|
|
* kinds (ephemeral npx/bunx, workspace links, project-local, unknown layouts):
|
|
* the caller must print a manual instruction rather than run a guessed command
|
|
* (running the wrong manager is worse than running nothing).
|
|
*/
|
|
export function installInvocation(kind: InstallerKind, version: string): InstallInvocation | null {
|
|
switch (kind) {
|
|
case "npm":
|
|
return { bin: "npm", args: ["install", "-g", `hyperframes@${version}`] };
|
|
case "bun":
|
|
return { bin: "bun", args: ["add", "-g", `hyperframes@${version}`] };
|
|
case "pnpm":
|
|
return { bin: "pnpm", args: ["add", "-g", `hyperframes@${version}`] };
|
|
case "brew":
|
|
// brew has no per-version install; `brew upgrade` moves to the tap's
|
|
// current formula (a no-op if the tap hasn't caught up).
|
|
return { bin: "brew", args: ["upgrade", "hyperframes"] };
|
|
case "skip":
|
|
return null;
|
|
}
|
|
}
|