mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
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:
@@ -30,7 +30,11 @@ import { join } from "node:path";
|
||||
import { compareVersions } from "compare-versions";
|
||||
import { readConfig, writeConfig } from "../telemetry/config.js";
|
||||
import { isDevMode } from "./env.js";
|
||||
import { detectInstaller } from "./installerDetection.js";
|
||||
import {
|
||||
detectInstaller,
|
||||
installInvocation,
|
||||
type InstallInvocation,
|
||||
} from "./installerDetection.js";
|
||||
|
||||
const CONFIG_DIR = join(homedir(), ".hyperframes");
|
||||
const LOG_FILE = join(CONFIG_DIR, "auto-update.log");
|
||||
@@ -74,22 +78,30 @@ function log(line: string): void {
|
||||
* the install that edits the config file in place. Keeps the whole thing to
|
||||
* one spawned process with no extra binary to distribute.
|
||||
*/
|
||||
function launchDetachedInstall(installCommand: string, version: string): void {
|
||||
function launchDetachedInstall(
|
||||
invocation: InstallInvocation,
|
||||
displayCommand: string,
|
||||
version: string,
|
||||
): void {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
const configFile = join(CONFIG_DIR, "config.json");
|
||||
|
||||
// The child script:
|
||||
// 1. Runs the install command, capturing exit code + stderr tail.
|
||||
// 1. Runs the install via execFile (bin + argv, NO shell) so a version
|
||||
// string can never be re-interpreted as shell syntax — structural
|
||||
// symmetry with the interactive `runDetectedInstall` path.
|
||||
// 2. Rewrites the config file with completedUpdate, clears pendingUpdate.
|
||||
// We shell out to `node -e` so we don't need to ship a separate file.
|
||||
// We run it through `node -e` so we don't need to ship a separate file. Bin
|
||||
// and args are embedded as JSON literals (data, not code).
|
||||
const nodeScript = `
|
||||
const { exec } = require("node:child_process");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { readFileSync, renameSync, writeFileSync } = require("node:fs");
|
||||
const CFG = ${JSON.stringify(configFile)};
|
||||
const TMP = \`\${CFG}.tmp\`;
|
||||
const VERSION = ${JSON.stringify(version)};
|
||||
const CMD = ${JSON.stringify(installCommand)};
|
||||
exec(CMD, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
||||
const BIN = ${JSON.stringify(invocation.bin)};
|
||||
const ARGS = ${JSON.stringify(invocation.args)};
|
||||
execFile(BIN, ARGS, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(readFileSync(CFG, "utf-8")); } catch (e) {}
|
||||
cfg.completedUpdate = {
|
||||
@@ -114,7 +126,7 @@ function launchDetachedInstall(installCommand: string, version: string): void {
|
||||
env: { ...process.env, HYPERFRAMES_NO_UPDATE_CHECK: "1", HYPERFRAMES_NO_AUTO_INSTALL: "1" },
|
||||
});
|
||||
child.unref();
|
||||
log(`[launch] pid=${child.pid ?? "?"} cmd=${installCommand} version=${version}`);
|
||||
log(`[launch] pid=${child.pid ?? "?"} cmd=${displayCommand} version=${version}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,7 +161,8 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion:
|
||||
return false;
|
||||
}
|
||||
const installCommand = installer.installCommand(latestVersion);
|
||||
if (!installCommand) return false;
|
||||
const invocation = installInvocation(installer.kind, latestVersion);
|
||||
if (!installCommand || !invocation) return false;
|
||||
|
||||
const config = readConfig();
|
||||
|
||||
@@ -177,7 +190,7 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion:
|
||||
writeConfig(config);
|
||||
|
||||
try {
|
||||
launchDetachedInstall(installCommand, latestVersion);
|
||||
launchDetachedInstall(invocation, installCommand, latestVersion);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`[error] spawn failed: ${String(err)}`);
|
||||
|
||||
Reference in New Issue
Block a user