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.
This commit is contained in:
WaterrrForever
2026-07-16 22:28:11 +08:00
committed by GitHub
parent 1b4cf12cd3
commit f8c33cab72
7 changed files with 88 additions and 10 deletions
@@ -12,7 +12,51 @@ vi.mock("../utils/updateCheck.js", async (orig) => ({
})),
}));
import { upgradeProjectPins } from "./upgrade.js";
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[] = [];
+28 -4
View File
@@ -40,13 +40,13 @@ export default defineCommand({
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) {
const p = resolveProjectArgs(args.project, { check: checkOnly, json: useJson });
const res = await upgradeProjectPins(resolve(p.dir), { json: p.json, check: p.check });
if (p.json) {
console.log(JSON.stringify(withMeta(res), null, 2));
return;
}
printProjectPinResult(res, checkOnly);
printProjectPinResult(res, p.check);
return;
}
@@ -157,6 +157,30 @@ function printManualCommands(displayCmd: string, npxFallback: string): void {
clack.outro(c.success("Run one of the commands above to upgrade."));
}
/**
* citty parses `--project` as a string option, so a bare `--project` followed
* by another flag consumes that flag as its value (`upgrade --project --check`
* arrives here as project="--check"). A leading dash can never be a real
* directory argument, so reclaim the eaten token as the flag the user wrote
* and fall back to the current directory.
*/
export function resolveProjectArgs(
project: string | boolean,
opts: { check: boolean; json: boolean },
): { dir: string; check: boolean; json: boolean } {
if (typeof project !== "string" || project.length === 0) {
return { dir: ".", check: opts.check, json: opts.json };
}
if (project.startsWith("-")) {
return {
dir: ".",
check: opts.check || project === "--check",
json: opts.json || project === "--json",
};
}
return { dir: project, check: opts.check, json: opts.json };
}
export async function upgradeProjectPins(
dir: string,
opts: { json: boolean; check: boolean },
+1 -1
View File
@@ -44,7 +44,7 @@ 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.
> **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
+1 -1
View File
@@ -44,7 +44,7 @@ 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.
> **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