mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +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.
238 lines
8.6 KiB
TypeScript
238 lines
8.6 KiB
TypeScript
/**
|
|
* Silent, lazy auto-update — Claude-Code-style.
|
|
*
|
|
* Flow across two runs of `hyperframes`:
|
|
*
|
|
* Run N → check registry, see latest > current, spawn detached
|
|
* installer child, write `pendingUpdate` marker. Exit normally
|
|
* without waiting. User's command is unaffected.
|
|
* (between) → detached child runs the installer, writes the outcome to
|
|
* `completedUpdate`, clears `pendingUpdate`.
|
|
* Run N+1 → detect `completedUpdate`, print one short line, clear the
|
|
* marker. The user is now on the new version.
|
|
*
|
|
* Guardrails:
|
|
* - Never auto-update across major versions. The user opts in explicitly
|
|
* via `hyperframes upgrade`.
|
|
* - Skip on CI, non-TTY, dev mode, unknown installer, ephemeral exec (npx),
|
|
* or when `HYPERFRAMES_NO_AUTO_INSTALL` / `HYPERFRAMES_NO_UPDATE_CHECK`
|
|
* is set.
|
|
* - If a previous install is still in flight (less than 10 min old), don't
|
|
* re-launch.
|
|
* - Installer output is redirected to `~/.hyperframes/auto-update.log` for
|
|
* postmortem; the user's terminal stays clean.
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { appendFileSync, mkdirSync, openSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { compareVersions } from "compare-versions";
|
|
import { readConfig, writeConfig } from "../telemetry/config.js";
|
|
import { isDevMode } from "./env.js";
|
|
import {
|
|
detectInstaller,
|
|
installInvocation,
|
|
type InstallInvocation,
|
|
} from "./installerDetection.js";
|
|
|
|
const CONFIG_DIR = join(homedir(), ".hyperframes");
|
|
const LOG_FILE = join(CONFIG_DIR, "auto-update.log");
|
|
/** An install that hasn't finished after this many ms is considered stuck. */
|
|
const PENDING_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
|
function isAutoInstallDisabled(): boolean {
|
|
if (isDevMode()) return true;
|
|
if (process.env["CI"] === "true" || process.env["CI"] === "1") return true;
|
|
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return true;
|
|
if (process.env["HYPERFRAMES_NO_AUTO_INSTALL"] === "1") return true;
|
|
return false;
|
|
}
|
|
|
|
/** Parse a semver-ish string's major number; returns NaN for pre-releases etc. */
|
|
function majorOf(version: string): number {
|
|
const match = /^(\d+)\./.exec(version);
|
|
return match?.[1] ? Number.parseInt(match[1], 10) : Number.NaN;
|
|
}
|
|
|
|
/**
|
|
* Quietly log a diagnostic line to `auto-update.log`. Never throws — a bad
|
|
* file write must not take down the CLI.
|
|
*/
|
|
function log(line: string): void {
|
|
try {
|
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
appendFileSync(LOG_FILE, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Spawn a detached child to run the install command. Stdout/stderr land in
|
|
* the log file; the child is `unref()`d so the parent exits immediately
|
|
* regardless of install duration.
|
|
*
|
|
* The child is responsible for writing `completedUpdate` to the config when
|
|
* it finishes — we express that by running a small inline Node command after
|
|
* 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(
|
|
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 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 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 { 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 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 = {
|
|
version: VERSION,
|
|
ok: !err,
|
|
finishedAt: new Date().toISOString(),
|
|
...(err ? { error: String(stderr || err.message || "install failed").slice(-400) } : {}),
|
|
};
|
|
delete cfg.pendingUpdate;
|
|
try {
|
|
writeFileSync(TMP, JSON.stringify(cfg, null, 2) + "\\n", { mode: 0o600 });
|
|
renameSync(TMP, CFG);
|
|
} catch (e) {}
|
|
});
|
|
`;
|
|
|
|
const out = openSync(LOG_FILE, "a", 0o600);
|
|
const child = spawn(process.execPath, ["-e", nodeScript], {
|
|
detached: true,
|
|
stdio: ["ignore", out, out],
|
|
windowsHide: true,
|
|
env: { ...process.env, HYPERFRAMES_NO_UPDATE_CHECK: "1", HYPERFRAMES_NO_AUTO_INSTALL: "1" },
|
|
});
|
|
child.unref();
|
|
log(`[launch] pid=${child.pid ?? "?"} cmd=${displayCommand} version=${version}`);
|
|
}
|
|
|
|
/**
|
|
* If a new version is available and policy allows, kick off a detached
|
|
* installer. Returns whether an install was spawned (for tests).
|
|
*/
|
|
export function scheduleBackgroundInstall(latestVersion: string, currentVersion: string): boolean {
|
|
if (isAutoInstallDisabled()) return false;
|
|
if (!latestVersion || !currentVersion) return false;
|
|
|
|
let cmp: number;
|
|
try {
|
|
cmp = compareVersions(latestVersion, currentVersion);
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (cmp <= 0) return false;
|
|
|
|
// Major-version jumps carry breaking-change risk. Don't silent-install;
|
|
// the existing `printUpdateNotice` banner nudges the user to run
|
|
// `hyperframes upgrade` explicitly.
|
|
const latestMajor = majorOf(latestVersion);
|
|
const currentMajor = majorOf(currentVersion);
|
|
if (Number.isFinite(latestMajor) && Number.isFinite(currentMajor) && latestMajor > currentMajor) {
|
|
log(`[skip] major-bump ${currentVersion} -> ${latestVersion}`);
|
|
return false;
|
|
}
|
|
|
|
const installer = detectInstaller();
|
|
if (installer.kind === "skip") {
|
|
log(`[skip] ${installer.reason}`);
|
|
return false;
|
|
}
|
|
const installCommand = installer.installCommand(latestVersion);
|
|
const invocation = installInvocation(installer.kind, latestVersion);
|
|
if (!installCommand || !invocation) return false;
|
|
|
|
const config = readConfig();
|
|
|
|
// Don't re-launch if a previous install is still fresh. Treat anything
|
|
// over PENDING_TIMEOUT_MS as stuck and let the next run supersede it.
|
|
if (config.pendingUpdate) {
|
|
const startedAt = Date.parse(config.pendingUpdate.startedAt);
|
|
const age = Number.isFinite(startedAt) ? Date.now() - startedAt : Number.POSITIVE_INFINITY;
|
|
if (age < PENDING_TIMEOUT_MS && config.pendingUpdate.version === latestVersion) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Skip if the previous completed outcome is already for this version and
|
|
// hasn't been surfaced yet — that run already did the work.
|
|
if (config.completedUpdate && config.completedUpdate.version === latestVersion) {
|
|
return false;
|
|
}
|
|
|
|
config.pendingUpdate = {
|
|
version: latestVersion,
|
|
command: installCommand,
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
writeConfig(config);
|
|
|
|
try {
|
|
launchDetachedInstall(invocation, installCommand, latestVersion);
|
|
return true;
|
|
} catch (err) {
|
|
log(`[error] spawn failed: ${String(err)}`);
|
|
const rollback = readConfig();
|
|
delete rollback.pendingUpdate;
|
|
writeConfig(rollback);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* If a previous run finished auto-installing, surface the outcome once.
|
|
* Successful installs are cleared immediately; failed installs stay marked so
|
|
* the scheduler can avoid retrying the same version on every invocation.
|
|
*/
|
|
export function reportCompletedUpdate(): void {
|
|
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return;
|
|
|
|
const config = readConfig();
|
|
const done = config.completedUpdate;
|
|
if (!done) return;
|
|
|
|
if (done.ok) {
|
|
delete config.completedUpdate;
|
|
writeConfig(config);
|
|
} else if (!done.reported) {
|
|
config.completedUpdate = { ...done, reported: true };
|
|
writeConfig(config);
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
if (!process.stderr.isTTY) return;
|
|
|
|
if (done.ok) {
|
|
process.stderr.write(` hyperframes auto-updated to v${done.version}\n\n`);
|
|
} else if (!done.reported) {
|
|
// Failed installs are surfaced once too — the user should know why the
|
|
// auto-update didn't take.
|
|
process.stderr.write(
|
|
` hyperframes auto-update to v${done.version} failed. Run \`hyperframes upgrade\` to retry.\n\n`,
|
|
);
|
|
}
|
|
}
|