From c1b1c729e52d1787f65d591363bd13f5587e0692 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 12 Jul 2026 14:00:05 -0700 Subject: [PATCH] feat(cli): hyperframes upgrade --project bumps pinned package.json scripts --- .../cli/src/commands/upgrade.project.test.ts | 44 +++++++++++++ packages/cli/src/commands/upgrade.ts | 64 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/cli/src/commands/upgrade.project.test.ts diff --git a/packages/cli/src/commands/upgrade.project.test.ts b/packages/cli/src/commands/upgrade.project.test.ts new file mode 100644 index 000000000..a08585931 --- /dev/null +++ b/packages/cli/src/commands/upgrade.project.test.ts @@ -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()), + 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 { + 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); + }); +}); diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts index 44f88126c..d370f849b 100644 --- a/packages/cli/src/commands/upgrade.ts +++ b/packages/cli/src/commands/upgrade.ts @@ -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@ 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; + }; + 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,