Files
hyperframes/packages/cli/src/commands/upgrade.project.test.ts
T
WaterrrForever f8c33cab72 feat(skills): act on stale CLI pin during project resume (#2540)
* feat(skills): probe and bump stale CLI pins during project resume

The entry skill now keeps a resumed project's pinned CLI current instead of
leaving that to a notice nobody acts on. On resuming a project with pinned
scripts, run the read-only probe 'npx hyperframes@latest upgrade --project
. --check'; when it (or the stale-pin stderr notice, or _meta.updateAvailable
from a pinned run) reports the project behind, apply the bump and verify
with 'hyperframes check'. A failed check reverts the bump and keeps the
project on its pinned version, preserving the reproducibility contract the
pin exists for.

The probe matters because the stale-pin notice only exists in >= 0.7.59:
a pinned run of an older CLI prints no warning at all, so a notice-only
trigger never fires for exactly the projects most behind. The probe runs
unpinned, so its behavior never depends on the project's CLI age.

Telemetry: the fleet converges to new releases within about a week via the
background auto-updater and ephemeral npx, but pinned projects form a
persistent stale tail (~10% of weekly actives, e.g. 6.3k users still on
0.6.x three weeks after 0.7.0).

Both skill surfaces now pass an explicit dir ('--project .') because a bare
'--project' followed by another flag consumes that flag as its directory
value and no-ops; the parsing fix is a separate CLI change.

* fix(cli): stop bare --project from eating the next flag as its directory

citty parses --project as a string option, so 'upgrade --project --check'
arrived with project="--check": the dir resolved to a nonexistent path and
the command no-opd with 'No package.json found' while --check was lost.
The documented default-cwd behavior only worked when --project was the
final token — and the trap-prone form is exactly what the scaffolded
template CLAUDE.md instructs.

A leading dash can never be a real directory argument, so resolveProjectArgs
now reclaims the eaten token as the flag the user wrote (--check / --json),
falls back to the current directory, and drops unrelated eaten flags rather
than treating them as paths. Templates and skill references switch to the
explicit-dir form ('--project .'), which behaves correctly on every release
including ones that predate this fix.

* feat(skills): report a successful pin bump in the run summary

Review follow-up on the stale-pin rule: 'hyperframes check' validates
composition structure, not render-output equivalence, so a check-passing
bump can still shift a project's rendered output. The bump stays the right
default for stale projects, but it must not be silent — the summary now
names the old and new version so the user knows the reproducibility
trade was made.
2026-07-16 22:28:11 +08:00

89 lines
2.9 KiB
TypeScript

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 { resolveProjectArgs, upgradeProjectPins } from "./upgrade.js";
describe("resolveProjectArgs", () => {
it("reclaims a flag eaten as the --project value", () => {
expect(resolveProjectArgs("--check", { check: false, json: false })).toEqual({
dir: ".",
check: true,
json: false,
});
expect(resolveProjectArgs("--json", { check: false, json: false })).toEqual({
dir: ".",
check: false,
json: true,
});
});
it("defaults to the current directory for a bare or boolean --project", () => {
expect(resolveProjectArgs(true, { check: true, json: false })).toEqual({
dir: ".",
check: true,
json: false,
});
expect(resolveProjectArgs("", { check: false, json: false })).toEqual({
dir: ".",
check: false,
json: false,
});
});
it("passes a real directory through untouched", () => {
expect(resolveProjectArgs("apps/site", { check: false, json: true })).toEqual({
dir: "apps/site",
check: false,
json: true,
});
});
it("drops an unrelated eaten flag instead of treating it as a directory", () => {
expect(resolveProjectArgs("--yes", { check: false, json: false })).toEqual({
dir: ".",
check: false,
json: false,
});
});
});
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);
});
});