mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(cli): give stale-pinned projects a path to the latest CLI (#2304)
## What
Already-scaffolded HyperFrames projects pin an exact `hyperframes@X.Y.Z` in `package.json` scripts (`init.ts`), frozen at scaffold time — and the update-available notice is suppressed on non-TTY shells, exactly how agents invoke the CLI. So a large tail of projects sits on months-old versions, invisible and stuck, never seeing later render/router fixes.
The pin itself is deliberate (a video project should re-render identically across CLI versions), so this PR keeps it and instead gives projects a path off it:
1. **`rewriteProjectPinnedScripts` / `readPinnedHyperframesVersions`** (`packages/cli/src/utils/projectPin.ts`) — pure helpers that rewrite/read `hyperframes@<version>` pins in a `package.json` scripts object.
2. **`hyperframes upgrade --project [dir]`** — bumps a project's pinned scripts to npm-latest in one command (`--check` reports the delta without writing, `--json` for `{ changed, from, to, path }`).
3. **`printStalePinNotice`** — a throttled (once/24h), non-TTY-visible notice (unlike the existing update notice, which non-TTY shells suppress) that fires when the *current* project's pin is stale, pointing at `upgrade --project`.
4. **Skill instruction** (`skills/hyperframes-cli/SKILL.md` + reference) — tells agents to check for and bump a stale project pin via the **unpinned** `npx hyperframes@latest upgrade --project`.
5. **Scaffold templates** (`CLAUDE.md`/`AGENTS.md`) — new projects get the same guidance baked in from day one.
## Why
Only the global skill (piece 4) invoking the unpinned `npx hyperframes@latest upgrade --project` (piece 2) reaches projects that are *already* frozen on an old pin — a project pinned to an old CLI version never runs the new notice code (piece 3) or sees the new template text (piece 5). Those two are forward-only: they stop the bleed on projects scaffolded from here on, but the skill instruction is the only lever that reaches the existing backlog.
## How
`isSafeVersion` was extracted out of `updateCheck.ts` into its own `safeVersion.ts` module — `projectPin.ts` needs it and `updateCheck.ts` needs `projectPin.ts`'s `readPinnedHyperframesVersions`, so keeping `isSafeVersion` in `updateCheck.ts` created a circular import between the two files.
## Test plan
- [x] Unit tests added/updated (`projectPin.test.ts`, `upgrade.project.test.ts`, `updateCheck.stalepin.test.ts`) — TDD, all passing
- [x] Full `packages/cli` suite green (132 files / 1651 tests), `tsc --noEmit` clean, `bun run build` succeeds
- [x] Manual smoke test: `upgrade --project --check --json` reports the delta without writing; `upgrade --project` rewrites the pinned scripts in place
- [ ] Documentation updated — skill + scaffold templates updated in this PR; `CLAUDE.md`/`AGENTS.md` template parity verified with `diff -q`
Deferred (left for a separate decision, not in this PR): `npm deprecate hyperframes@"<0.7.53"` — reaches frozen projects with no skill loaded, but is a live, hard-to-reverse action against published packages that needs an explicit human call on cutoff version + message.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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?.();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
vi.mock("../utils/updateCheck.js", async (orig) => ({
|
||||
...(await orig<typeof import("../utils/updateCheck.js")>()),
|
||||
checkForUpdate: vi.fn(async () => ({
|
||||
current: "0.7.48",
|
||||
latest: "0.7.55",
|
||||
updateAvailable: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { upgradeProjectPins } from "./upgrade.js";
|
||||
|
||||
describe("upgradeProjectPins", () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true })));
|
||||
function project(scripts: Record<string, string>): string {
|
||||
const d = mkdtempSync(join(tmpdir(), "hf-proj-"));
|
||||
dirs.push(d);
|
||||
writeFileSync(join(d, "package.json"), JSON.stringify({ name: "x", scripts }, null, 2));
|
||||
return d;
|
||||
}
|
||||
|
||||
it("rewrites pinned scripts to latest and reports the delta", async () => {
|
||||
const d = project({ render: "npx --yes hyperframes@0.7.48 render" });
|
||||
const r = await upgradeProjectPins(d, { json: false, check: false });
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.from).toEqual(["0.7.48"]);
|
||||
expect(r.to).toBe("0.7.55");
|
||||
const pkg = JSON.parse(readFileSync(join(d, "package.json"), "utf-8"));
|
||||
expect(pkg.scripts.render).toBe("npx --yes hyperframes@0.7.55 render");
|
||||
});
|
||||
|
||||
it("--check reports without writing", async () => {
|
||||
const d = project({ render: "npx --yes hyperframes@0.7.48 render" });
|
||||
const before = readFileSync(join(d, "package.json"), "utf-8");
|
||||
const r = await upgradeProjectPins(d, { json: false, check: true });
|
||||
expect(r.changed).toBe(true);
|
||||
expect(readFileSync(join(d, "package.json"), "utf-8")).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,16 @@ import { defineCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import * as clack from "@clack/prompts";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { rewriteProjectPinnedScripts } from "../utils/projectPin.js";
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Check for updates interactively", "hyperframes upgrade"],
|
||||
["Check for updates without prompting", "hyperframes upgrade --check"],
|
||||
["Upgrade non-interactively", "hyperframes upgrade --yes"],
|
||||
["Bump a project's pinned CLI scripts", "hyperframes upgrade --project"],
|
||||
];
|
||||
import { VERSION } from "../version.js";
|
||||
import {
|
||||
@@ -24,12 +28,28 @@ export default defineCommand({
|
||||
yes: { type: "boolean", alias: "y", description: "Show upgrade commands without prompting" },
|
||||
check: { type: "boolean", description: "Check for updates and exit (no prompt)" },
|
||||
json: { type: "boolean", description: "Output as JSON", default: false },
|
||||
project: {
|
||||
type: "string",
|
||||
description:
|
||||
"Bump this project's package.json hyperframes@<version> script pins to latest (default: current dir)",
|
||||
},
|
||||
},
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
const useJson = args.json === true;
|
||||
const checkOnly = args.check === true;
|
||||
|
||||
if (args.project !== undefined) {
|
||||
const dir = typeof args.project === "string" && args.project.length ? args.project : ".";
|
||||
const res = await upgradeProjectPins(resolve(dir), { json: useJson, check: checkOnly });
|
||||
if (useJson) {
|
||||
console.log(JSON.stringify(withMeta(res), null, 2));
|
||||
return;
|
||||
}
|
||||
printProjectPinResult(res, checkOnly);
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON mode: always force-check and output structured data
|
||||
if (useJson) {
|
||||
const result = await checkForUpdate(true);
|
||||
@@ -137,6 +157,50 @@ function printManualCommands(displayCmd: string, npxFallback: string): void {
|
||||
clack.outro(c.success("Run one of the commands above to upgrade."));
|
||||
}
|
||||
|
||||
export async function upgradeProjectPins(
|
||||
dir: string,
|
||||
opts: { json: boolean; check: boolean },
|
||||
): Promise<{ changed: boolean; from: string[]; to: string; path: string }> {
|
||||
const pkgPath = resolve(dir, "package.json");
|
||||
if (!existsSync(pkgPath)) {
|
||||
return { changed: false, from: [], to: "", path: pkgPath };
|
||||
}
|
||||
const raw = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const scripts = raw.scripts ?? {};
|
||||
const { latest } = await checkForUpdate(true);
|
||||
if (!isSafeVersion(latest)) return { changed: false, from: [], to: latest, path: pkgPath };
|
||||
const rewrite = rewriteProjectPinnedScripts(scripts, latest);
|
||||
if (rewrite.changed && !opts.check) {
|
||||
raw.scripts = rewrite.scripts;
|
||||
const tmp = `${pkgPath}.tmp`;
|
||||
writeFileSync(tmp, `${JSON.stringify(raw, null, 2)}\n`, "utf-8");
|
||||
renameSync(tmp, pkgPath);
|
||||
}
|
||||
return { changed: rewrite.changed, from: rewrite.fromVersions, to: latest, path: pkgPath };
|
||||
}
|
||||
|
||||
function printProjectPinResult(
|
||||
res: { changed: boolean; from: string[]; to: string; path: string },
|
||||
checkOnly: boolean,
|
||||
): void {
|
||||
if (!existsSync(res.path)) {
|
||||
console.log(` ${c.dim("No package.json found — nothing to upgrade.")}`);
|
||||
return;
|
||||
}
|
||||
if (!res.changed) {
|
||||
console.log(` ${c.success("◇")} Project scripts already on hyperframes@${res.to}`);
|
||||
return;
|
||||
}
|
||||
const verb = checkOnly ? "would bump" : "bumped";
|
||||
console.log(
|
||||
` ${c.success("◇")} ${verb} project scripts ${res.from.join(", ")} → ${c.accent(res.to)}`,
|
||||
);
|
||||
if (checkOnly)
|
||||
console.log(` ${c.dim("Run `npx hyperframes@latest upgrade --project` to apply.")}`);
|
||||
}
|
||||
|
||||
export function runDetectedInstall(
|
||||
invocation: { bin: string; args: string[] },
|
||||
displayCmd: string,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,6 +45,8 @@ npx hyperframes docs <topic> # reference docs in terminal
|
||||
> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
|
||||
> command — it will time out and the server will die, breaking the browser preview.
|
||||
|
||||
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project --check` (shows the delta), then `npx hyperframes@latest upgrade --project` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
|
||||
|
||||
## Documentation
|
||||
|
||||
**For quick reference**, use the local CLI docs command (no network required):
|
||||
|
||||
@@ -45,6 +45,8 @@ npx hyperframes docs <topic> # reference docs in terminal
|
||||
> In Claude Code, always run it with `run_in_background: true`. Never run it as a foreground
|
||||
> command — it will time out and the server will die, breaking the browser preview.
|
||||
|
||||
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project --check` (shows the delta), then `npx hyperframes@latest upgrade --project` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
|
||||
|
||||
## Documentation
|
||||
|
||||
**For quick reference**, use the local CLI docs command (no network required):
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
rewriteProjectPinnedScripts,
|
||||
readPinnedHyperframesVersions,
|
||||
HYPERFRAMES_PIN_RE,
|
||||
} from "./projectPin.js";
|
||||
|
||||
describe("HYPERFRAMES_PIN_RE", () => {
|
||||
it("matches a hyperframes@<semver> token and captures the version", () => {
|
||||
const match = "npx --yes hyperframes@1.2.3 render".match(HYPERFRAMES_PIN_RE);
|
||||
expect(match?.[0]).toBe("hyperframes@1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteProjectPinnedScripts", () => {
|
||||
const scripts = {
|
||||
dev: "npx --yes hyperframes@0.7.48 preview",
|
||||
check: "npx --yes hyperframes@0.7.48 check",
|
||||
render: "npx --yes hyperframes@0.7.48 render",
|
||||
unrelated: "echo hi",
|
||||
unpinned: "npx hyperframes render",
|
||||
};
|
||||
|
||||
it("bumps every pinned hyperframes script to the target, leaving others untouched", () => {
|
||||
const r = rewriteProjectPinnedScripts(scripts, "0.7.55");
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.fromVersions).toEqual(["0.7.48"]);
|
||||
expect(r.scripts.render).toBe("npx --yes hyperframes@0.7.55 render");
|
||||
expect(r.scripts.dev).toBe("npx --yes hyperframes@0.7.55 preview");
|
||||
expect(r.scripts.unrelated).toBe("echo hi");
|
||||
expect(r.scripts.unpinned).toBe("npx hyperframes render");
|
||||
});
|
||||
|
||||
it("is a no-op when already at target", () => {
|
||||
const at = rewriteProjectPinnedScripts(
|
||||
{ render: "npx --yes hyperframes@0.7.55 render" },
|
||||
"0.7.55",
|
||||
);
|
||||
expect(at.changed).toBe(false);
|
||||
expect(at.fromVersions).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses an unsafe target version (no rewrite)", () => {
|
||||
const r = rewriteProjectPinnedScripts(scripts, "0.7.55; rm -rf /");
|
||||
expect(r.changed).toBe(false);
|
||||
expect(r.scripts.render).toBe(scripts.render);
|
||||
});
|
||||
|
||||
it("reads distinct pinned versions across scripts", () => {
|
||||
expect(
|
||||
readPinnedHyperframesVersions({
|
||||
a: "npx --yes hyperframes@0.7.48 render",
|
||||
b: "npx hyperframes@0.7.50 check",
|
||||
c: "npx hyperframes render",
|
||||
}),
|
||||
).toEqual(["0.7.48", "0.7.50"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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
|
||||
// old version. `(?=\s|$)` keeps it from matching a longer package name.
|
||||
export const HYPERFRAMES_PIN_RE =
|
||||
/\bhyperframes@([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?=\s|$)/g;
|
||||
|
||||
export interface PinRewriteResult {
|
||||
changed: boolean;
|
||||
scripts: Record<string, string>;
|
||||
fromVersions: string[];
|
||||
}
|
||||
|
||||
export function readPinnedHyperframesVersions(scripts: Record<string, string>): string[] {
|
||||
const found = new Set<string>();
|
||||
for (const cmd of Object.values(scripts ?? {})) {
|
||||
for (const m of cmd.matchAll(HYPERFRAMES_PIN_RE)) if (m[1]) found.add(m[1]);
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
export function rewriteProjectPinnedScripts(
|
||||
scripts: Record<string, string>,
|
||||
targetVersion: string,
|
||||
): PinRewriteResult {
|
||||
// Never emit an unverified version into a script the user (or npx) will run.
|
||||
if (!isSafeVersion(targetVersion)) {
|
||||
return { changed: false, scripts: { ...scripts }, fromVersions: [] };
|
||||
}
|
||||
const fromVersions = new Set<string>();
|
||||
const next: Record<string, string> = {};
|
||||
for (const [name, cmd] of Object.entries(scripts ?? {})) {
|
||||
next[name] = cmd.replace(HYPERFRAMES_PIN_RE, (_full, version: string) => {
|
||||
if (version !== targetVersion) fromVersions.add(version);
|
||||
return `hyperframes@${targetVersion}`;
|
||||
});
|
||||
}
|
||||
return {
|
||||
changed: [...fromVersions].length > 0,
|
||||
scripts: next,
|
||||
fromVersions: [...fromVersions].sort(),
|
||||
};
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"files": 102
|
||||
},
|
||||
"hyperframes-cli": {
|
||||
"hash": "f78d0c936fd3499f",
|
||||
"hash": "fbe17780248d5bec",
|
||||
"files": 8
|
||||
},
|
||||
"hyperframes-core": {
|
||||
|
||||
@@ -40,6 +40,7 @@ Cross-cutting rules that hold for every command:
|
||||
- **Use Studio context for user-directed edits.** When the user says "this selected element", "the thing I clicked", "current selection", or similar, ask them to select it in Studio, then run `npx hyperframes preview --context --json --context-fields selection`. Use the returned `selection.target.hfId` / `selector`, `selection.sourceFile`, `selection.currentTime`, and `selection.thumbnailUrl` to anchor the edit. If `selection` is `null` and `errors.selection.code` is `no-selection`, ask the user to click the element and rerun; do not guess from screenshots.
|
||||
- **Keep Studio context compact.** `preview --context --json` returns compact selection by default. Add `--context-fields selection`, `--context-fields selection,lint`, or `--context-fields lint` to avoid bloating agent context. Use `--context-detail full` only when you need heavy fields like computed styles, inline styles, or text-field metadata.
|
||||
- **Post-render verification.** After `render` returns exit 0, confirm the output file exists and has plausible size before reporting success: `[ -s "$OUTPUT" ] || echo "render produced no output"`. The CLI prints `◇ <path>` on success; for long renders also sanity-check duration with `ffprobe -i "$OUTPUT" -show_format -v error`.
|
||||
- **Keep the project's pinned CLI fresh.** `hyperframes init` writes an exact version into `package.json` scripts (`npx --yes hyperframes@X.Y.Z render`) so a project re-renders identically over time — but a project scaffolded weeks ago is frozen on a CLI that predates render, capture-router, and correctness fixes it would silently benefit from. Once per project, check whether the pin is stale and bump it: `npx hyperframes@latest upgrade --project --check` (reports the delta, writes nothing), then `npx hyperframes@latest upgrade --project` to apply. Use the **unpinned** `hyperframes@latest` form — the pinned script would just re-run the old version against itself. Skip if the user deliberately pinned a version.
|
||||
- **Report feedback after a successful render.** Once the output is verified, run `npx hyperframes feedback --rating <0-10> --comment "..."` once per task to report how likely you are to recommend the run — `10` for a clean run, lower when you hit friction. Put any bug, workaround, missing feature, or confusing behaviour you encountered in `--comment`. This is the project's primary signal channel; renders that go silent leave the maintainers blind. Skip only when telemetry is disabled or the user opted out.
|
||||
- **For a reproducible bug, add `--file-issue`** — it publishes a minimal repro of the project to a public URL and opens a pre-filled GitHub issue with your comment + environment. A comment alone almost never lets a maintainer reproduce; the published composition is what makes the report actionable. Point `--dir <project>` at the failing composition if it isn't the current directory.
|
||||
- **Write bug comments a maintainer can reproduce from — a paraphrase they can't act on wastes the report.** The CLI already attaches os/arch/mem/ffmpeg/CLI-version, so don't repeat those; spend the comment on what only you saw. For a bug, include every one of these that applies:
|
||||
|
||||
@@ -23,6 +23,8 @@ npx hyperframes upgrade --yes # print upgrade commands without promptin
|
||||
|
||||
Compares the installed CLI version against npm latest.
|
||||
|
||||
`--project [dir]` bumps a **project's** pinned scripts instead of the global install: it rewrites every `npx …hyperframes@<version>…` in `<dir>/package.json` (default cwd) to npm-latest. Always invoke it unpinned (`npx hyperframes@latest upgrade --project`) — a project scaffolded on an old CLI stays frozen otherwise. `--project --check` reports the delta without writing; add `--json` for `{ changed, from, to, path }`.
|
||||
|
||||
## compositions, docs
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user