From 6fec8f39ae3eb336648b4c5f5323669c78f43ce9 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 12 Jul 2026 13:58:25 -0700 Subject: [PATCH] feat(cli): add project-pin rewrite helper --- packages/cli/src/utils/projectPin.test.ts | 58 +++++++++++++++++++++++ packages/cli/src/utils/projectPin.ts | 44 +++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 packages/cli/src/utils/projectPin.test.ts create mode 100644 packages/cli/src/utils/projectPin.ts diff --git a/packages/cli/src/utils/projectPin.test.ts b/packages/cli/src/utils/projectPin.test.ts new file mode 100644 index 000000000..18a60e725 --- /dev/null +++ b/packages/cli/src/utils/projectPin.test.ts @@ -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@ 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"]); + }); +}); diff --git a/packages/cli/src/utils/projectPin.ts b/packages/cli/src/utils/projectPin.ts new file mode 100644 index 000000000..3eff53096 --- /dev/null +++ b/packages/cli/src/utils/projectPin.ts @@ -0,0 +1,44 @@ +import { isSafeVersion } from "./updateCheck.js"; + +// Matches `hyperframes@` 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; + fromVersions: string[]; +} + +export function readPinnedHyperframesVersions(scripts: Record): string[] { + const found = new Set(); + 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, + 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(); + const next: Record = {}; + 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(), + }; +}