mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(cli): surface stale project pin to non-TTY agents, throttled
This commit is contained in:
@@ -206,6 +206,7 @@ let _trackCommandResult:
|
||||
}) => void)
|
||||
| undefined;
|
||||
let _printUpdateNotice: (() => void) | undefined;
|
||||
let _printStalePinNotice: (() => void) | undefined;
|
||||
let _printSkillsUpdateNotice: (() => void) | undefined;
|
||||
|
||||
// `events` is a telemetry-internal beacon: it self-tracks + self-flushes, so it
|
||||
@@ -245,6 +246,7 @@ if (
|
||||
|
||||
import("./utils/updateCheck.js").then(async (mod) => {
|
||||
_printUpdateNotice = mod.printUpdateNotice;
|
||||
_printStalePinNotice = mod.printStalePinNotice;
|
||||
const result = await mod.checkForUpdate().catch(() => null);
|
||||
if (result?.updateAvailable) {
|
||||
const auto = await import("./utils/autoUpdate.js").catch(() => null);
|
||||
@@ -268,10 +270,12 @@ const runId = getRunId();
|
||||
// work — so a plain `on` listener would print the update notice (and
|
||||
// re-flush) once per drain (the user-reported double-print). `once`
|
||||
// detaches after first invocation, which is what we want for both.
|
||||
// fallow-ignore-next-line complexity
|
||||
process.once("beforeExit", () => {
|
||||
_flush?.().catch(() => {});
|
||||
if (!hasJsonFlag) {
|
||||
_printUpdateNotice?.();
|
||||
_printStalePinNotice?.();
|
||||
_printSkillsUpdateNotice?.();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface HyperframesConfig {
|
||||
lastUpdateCheck?: string;
|
||||
/** Latest version found on npm */
|
||||
latestVersion?: string;
|
||||
/** Throttle for the non-TTY stale-project-pin notice (ms epoch). */
|
||||
lastStalePinNoticeAt?: number;
|
||||
/**
|
||||
* Auto-update marker. Set when a background install is spawned so a
|
||||
* subsequent run can skip re-triggering it. Cleared once
|
||||
@@ -122,6 +124,7 @@ export function readConfig(): HyperframesConfig {
|
||||
lastFeedbackPromptAt: parsed.lastFeedbackPromptAt ?? DEFAULT_CONFIG.lastFeedbackPromptAt,
|
||||
lastUpdateCheck: parsed.lastUpdateCheck,
|
||||
latestVersion: parsed.latestVersion,
|
||||
lastStalePinNoticeAt: parsed.lastStalePinNoticeAt,
|
||||
pendingUpdate: parsed.pendingUpdate,
|
||||
completedUpdate: parsed.completedUpdate,
|
||||
lastSkillsCheck: parsed.lastSkillsCheck,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isSafeVersion } from "./updateCheck.js";
|
||||
import { isSafeVersion } from "./safeVersion.js";
|
||||
|
||||
// Matches `hyperframes@<semver>` as a whole token inside a script string. The
|
||||
// version class mirrors isSafeVersion's semver shape; capturing group 1 is the
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* True when `v` is a strict semver-shaped string. Registry-supplied versions
|
||||
* flow into commands that are displayed AND executed (the `upgrade` command and
|
||||
* the background auto-installer both run them), so a poisoned `latest` carrying
|
||||
* shell metacharacters must never reach them. This is enforced at the registry
|
||||
* boundary in `checkForUpdate` — an unsafe `data.version` is never cached — so
|
||||
* every consumer (notice, upgrade, background auto-install, and any future one)
|
||||
* is covered by this single gate; the per-consumer checks are defense in depth.
|
||||
*
|
||||
* Lives in its own module (rather than updateCheck.ts) so utils/projectPin.ts
|
||||
* can depend on it without a projectPin.ts <-> updateCheck.ts import cycle —
|
||||
* updateCheck.ts imports readPinnedHyperframesVersions from projectPin.ts.
|
||||
*/
|
||||
export function isSafeVersion(v: string): boolean {
|
||||
return /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Drive `latest` through the REAL getUpdateMeta (defined in the module under
|
||||
// test) via the mocked config store — self-mocking getUpdateMeta on
|
||||
// "./updateCheck.js" would only override the export binding, not the internal
|
||||
// call printStalePinNotice makes to it from within the same module.
|
||||
let store: Record<string, unknown> = {};
|
||||
// isDevMode() is true under vitest (module path ends in .ts), which would
|
||||
// suppress the notice unconditionally — mock ./env.js like updateCheck.test.ts does.
|
||||
vi.mock("./env.js", () => ({ isDevMode: () => false }));
|
||||
vi.mock("../telemetry/config.js", () => ({
|
||||
readConfig: () => ({ ...store }),
|
||||
writeConfig: (c: Record<string, unknown>) => {
|
||||
store = { ...c };
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
import { printStalePinNotice } from "./updateCheck.js";
|
||||
|
||||
describe("printStalePinNotice", () => {
|
||||
let dir: string;
|
||||
let writes: string[];
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
beforeEach(() => {
|
||||
store = { latestVersion: "0.7.55" };
|
||||
writes = [];
|
||||
dir = mkdtempSync(join(tmpdir(), "hf-pin-"));
|
||||
process.stderr.write = ((s: unknown) => {
|
||||
writes.push(String(s));
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
delete process.env.CI;
|
||||
delete process.env.HYPERFRAMES_NO_UPDATE_CHECK;
|
||||
});
|
||||
afterEach(() => {
|
||||
process.stderr.write = origWrite;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("warns once when the project pins an older version", () => {
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ scripts: { render: "npx --yes hyperframes@0.7.48 render" } }),
|
||||
);
|
||||
printStalePinNotice(dir);
|
||||
printStalePinNotice(dir); // throttled — second call silent
|
||||
expect(writes.join("")).toContain("0.7.48");
|
||||
expect(writes.join("")).toContain("upgrade --project");
|
||||
expect(writes.filter((w) => w.includes("upgrade --project")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("silent when project pin is current", () => {
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ scripts: { render: "npx --yes hyperframes@0.7.55 render" } }),
|
||||
);
|
||||
printStalePinNotice(dir);
|
||||
expect(writes.join("")).toBe("");
|
||||
});
|
||||
|
||||
it("silent under CI", () => {
|
||||
process.env.CI = "true";
|
||||
writeFileSync(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ scripts: { render: "npx --yes hyperframes@0.7.48 render" } }),
|
||||
);
|
||||
printStalePinNotice(dir);
|
||||
expect(writes.join("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,14 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { compareVersions } from "compare-versions";
|
||||
import { readConfig, writeConfig } from "../telemetry/config.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { isDevMode } from "./env.js";
|
||||
import { detectInstaller } from "./installerDetection.js";
|
||||
import { readPinnedHyperframesVersions } from "./projectPin.js";
|
||||
import { isSafeVersion } from "./safeVersion.js";
|
||||
|
||||
/**
|
||||
* True when `v` is a strict semver-shaped string. Registry-supplied versions
|
||||
* flow into commands that are displayed AND executed (the `upgrade` command and
|
||||
* the background auto-installer both run them), so a poisoned `latest` carrying
|
||||
* shell metacharacters must never reach them. This is enforced at the registry
|
||||
* boundary in `checkForUpdate` — an unsafe `data.version` is never cached — so
|
||||
* every consumer (notice, upgrade, background auto-install, and any future one)
|
||||
* is covered by this single gate; the per-consumer checks are defense in depth.
|
||||
*/
|
||||
export function isSafeVersion(v: string): boolean {
|
||||
return /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(v);
|
||||
}
|
||||
export { isSafeVersion } from "./safeVersion.js";
|
||||
|
||||
const NPM_REGISTRY_URL = "https://registry.npmjs.org/hyperframes/latest";
|
||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -195,3 +188,50 @@ export function printUpdateNotice(): void {
|
||||
` Run: ${command}\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const STALE_PIN_THROTTLE_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Actionable, throttled notice for a project whose package.json still pins an
|
||||
* OLD hyperframes version. Unlike printUpdateNotice this DOES fire on non-TTY
|
||||
* (agents render with piped stderr) \u2014 but only when there's a concrete stale
|
||||
* pin to act on, at most once/24h per install, and never under --json/CI/dev/
|
||||
* opt-out. The whole cli.ts update block is already skipped for --json, so a
|
||||
* JSON stdout stays clean regardless.
|
||||
*/
|
||||
export function printStalePinNotice(cwd: string = process.cwd()): void {
|
||||
if (isDevMode()) return;
|
||||
if (process.env["CI"] === "true" || process.env["CI"] === "1") return;
|
||||
if (process.env["HYPERFRAMES_NO_UPDATE_CHECK"] === "1") return;
|
||||
|
||||
const latest = getUpdateMeta().latestVersion;
|
||||
if (!latest || !isSafeVersion(latest)) return;
|
||||
|
||||
let scripts: Record<string, string> = {};
|
||||
try {
|
||||
const pkgPath = join(cwd, "package.json");
|
||||
if (!existsSync(pkgPath)) return;
|
||||
scripts = (JSON.parse(readFileSync(pkgPath, "utf-8")).scripts ?? {}) as Record<string, string>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const stale = readPinnedHyperframesVersions(scripts).filter((v) => {
|
||||
try {
|
||||
return compareVersions(latest, v) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (stale.length === 0) return;
|
||||
|
||||
const config = readConfig();
|
||||
const last = config.lastStalePinNoticeAt ?? 0;
|
||||
if (Date.now() - last < STALE_PIN_THROTTLE_MS) return;
|
||||
config.lastStalePinNoticeAt = Date.now();
|
||||
writeConfig(config);
|
||||
|
||||
process.stderr.write(
|
||||
`\n This project pins hyperframes@${stale.join(", ")} (latest ${latest}).\n` +
|
||||
` Bump it: npx hyperframes@latest upgrade --project\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user