mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 12:00:26 +00:00
fix(cli): make skills update converge on a skill retired upstream (#2176)
`hyperframes skills update` failed hard or looped forever once a skill was
retired/renamed upstream while still installed locally (hyperframes-media folded
into media-use; hyperframes-captions/compose/tts consolidated earlier). Two
paths dead-ended:
- Install: target selection could trust a stale local skills-manifest.json
(findRepoManifest) while `skills add` always installs from the canonical repo.
isCoreSkill matches the `hyperframes-` prefix, so a retired skill was forced
into the target set, `skills add` silently declined it (exit 0), and strict
verifyInstalled threw "Skill(s) still missing after install".
- Prune: upstream `skills remove` scans on-disk directories, so a lock entry
retired before it ever shipped a bundle has nothing to match — a silent
exit-0 no-op that never clears the lock, so detectRemoved re-flags it on
every run.
The stale-skills nudge compounded it: it fired even from `skills update` itself
(pointing users back at the failing command) and its count ignored the removed
bucket.
Resolve update targets against the canonical manifest (checkSkills({ canonical:
true })) so a retired skill is never targeted. Add pruneOrphanedLockEntries to
clear the orphaned lock entries the upstream remover can't (idempotent, so a
second run is a clean no-op). Exclude `skills` from the update-nudge gate and
thread the removed count through the nudge total.
This commit is contained in:
@@ -97,6 +97,11 @@ vi.mock("../utils/skillsManifest.js", async (importOriginal) => {
|
||||
checkSkills: vi.fn(async () => DEFAULT_CHECK),
|
||||
hyperframesSkillNames: vi.fn(() => ["hyperframes"]),
|
||||
presentSkills: vi.fn((names: readonly string[]) => [...names]),
|
||||
// Default: nothing left to prune after `runSkillsRemove`. The real
|
||||
// (unmocked) fs-level behavior is covered in skillsManifest.test.ts;
|
||||
// here we only assert the wiring — what update passes in, and that it
|
||||
// isn't reached when there's nothing removed.
|
||||
pruneOrphanedLockEntries: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -383,6 +388,81 @@ describe("hyperframes skills", () => {
|
||||
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
|
||||
});
|
||||
|
||||
// Retired-skill regression (variant 1): the update engine's OWN targeted-
|
||||
// install check must resolve the canonical (published) manifest, never a
|
||||
// stale local `skills-manifest.json` a checkout might still have lying
|
||||
// around — see resolveLatestManifest's in-repo shortcut. Without this, a
|
||||
// skill retired upstream but still listed locally gets forced into
|
||||
// `targets` (isCoreSkill pattern-matches `hyperframes-*`), `skills add`
|
||||
// silently declines to install something that doesn't exist canonically,
|
||||
// and the old code strict-threw on a "failure" that was never real.
|
||||
it("checks freshness against the canonical manifest, never a possibly-stale local one", async () => {
|
||||
setPlatform("linux");
|
||||
const { checkSkills } = await import("../utils/skillsManifest.js");
|
||||
|
||||
await runSkillsUpdate();
|
||||
|
||||
// The update engine's own check (first call) must ask for canonical;
|
||||
// the prune's check (last call, tested separately) intentionally doesn't.
|
||||
expect(checkSkills).toHaveBeenNthCalledWith(1, expect.objectContaining({ canonical: true }));
|
||||
});
|
||||
|
||||
// Retired-skill regression (variant 2): `skills remove` is a silent no-op
|
||||
// for a lock entry with no on-disk bundle (upstream scans disk, not the
|
||||
// lock, to decide what's "installed" — see pruneOrphanedLockEntries's
|
||||
// doc comment). `skills update` must self-heal that lock entry itself so
|
||||
// `check || update` actually converges instead of re-flagging it forever.
|
||||
it("self-heals an orphaned lock entry after `skills remove` no-ops on it", async () => {
|
||||
setPlatform("linux");
|
||||
const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js");
|
||||
vi.mocked(checkSkills)
|
||||
.mockResolvedValueOnce(DEFAULT_CHECK as never)
|
||||
.mockResolvedValueOnce({
|
||||
scope: "global",
|
||||
skills: [{ name: "hyperframes-captions", status: "removed" }],
|
||||
} as never);
|
||||
vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]);
|
||||
|
||||
await runSkillsUpdate();
|
||||
|
||||
expect(pruneOrphanedLockEntries).toHaveBeenCalledWith(["hyperframes-captions"], "global");
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
// The idempotent-second-run contract at the command level: once nothing is
|
||||
// left attributed as removed (the fs-level idempotency of the prune itself
|
||||
// is covered directly in skillsManifest.test.ts), a second `skills update`
|
||||
// must be a clean no-op — no `skills remove` spawn, no prune call finding
|
||||
// anything, still exit 0.
|
||||
it("running update twice in a row converges — the second run prunes nothing", async () => {
|
||||
setPlatform("linux");
|
||||
const { checkSkills, pruneOrphanedLockEntries } = await import("../utils/skillsManifest.js");
|
||||
vi.mocked(checkSkills)
|
||||
.mockResolvedValueOnce(DEFAULT_CHECK as never)
|
||||
.mockResolvedValueOnce({
|
||||
scope: "global",
|
||||
skills: [{ name: "hyperframes-captions", status: "removed" }],
|
||||
} as never);
|
||||
vi.mocked(pruneOrphanedLockEntries).mockReturnValueOnce(["hyperframes-captions"]);
|
||||
|
||||
await runSkillsUpdate();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(true);
|
||||
|
||||
// Second run: nothing attributed as removed anymore (the lock entry was
|
||||
// pruned above), so there's nothing left to reconcile.
|
||||
state.spawnCalls = [];
|
||||
vi.mocked(checkSkills)
|
||||
.mockResolvedValueOnce(DEFAULT_CHECK as never)
|
||||
.mockResolvedValueOnce({ scope: "global", skills: [] } as never);
|
||||
|
||||
await runSkillsUpdate();
|
||||
expect(process.exitCode).toBe(0);
|
||||
expect(state.spawnCalls.some((s) => s.args.includes("remove"))).toBe(false);
|
||||
// Nothing to prune this time — pruneOrphanedLockEntries isn't even reached.
|
||||
expect(pruneOrphanedLockEntries).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// `update`'s prune runs the same removed-detection as `check`, so its
|
||||
// --source/--dir must reach the internal checkSkills() — otherwise the prune
|
||||
// reconciles against defaults even when the user pointed elsewhere.
|
||||
@@ -615,6 +695,40 @@ describe("hyperframes skills update <names>", () => {
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("a malformed canonical manifest warns distinctly, then still degrades to presence mode", async () => {
|
||||
setPlatform("linux");
|
||||
const clack = await import("@clack/prompts");
|
||||
vi.mocked(clack.log.warn).mockClear();
|
||||
const { checkSkills } = await import("../utils/skillsManifest.js");
|
||||
vi.mocked(checkSkills).mockRejectedValue(
|
||||
new Error("Malformed skills manifest from https://raw.githubusercontent.com/…"),
|
||||
);
|
||||
|
||||
await runSkillsUpdateWith(["pr-to-video"]);
|
||||
|
||||
const warnedMalformed = vi
|
||||
.mocked(clack.log.warn)
|
||||
.mock.calls.some((args) => String(args[0]).includes("malformed"));
|
||||
expect(warnedMalformed).toBe(true);
|
||||
// Still degrades rather than failing the whole command.
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("a genuine offline error degrades silently — no malformed-manifest warning", async () => {
|
||||
setPlatform("linux");
|
||||
const clack = await import("@clack/prompts");
|
||||
vi.mocked(clack.log.warn).mockClear();
|
||||
const { checkSkills } = await import("../utils/skillsManifest.js");
|
||||
vi.mocked(checkSkills).mockRejectedValue(new Error("fetch failed"));
|
||||
|
||||
await runSkillsUpdateWith(["pr-to-video"]);
|
||||
|
||||
const warnedMalformed = vi
|
||||
.mocked(clack.log.warn)
|
||||
.mock.calls.some((args) => String(args[0]).includes("malformed"));
|
||||
expect(warnedMalformed).toBe(false);
|
||||
});
|
||||
|
||||
it("--json emits a parseable result on success", async () => {
|
||||
setPlatform("linux");
|
||||
const logSpy = vi.spyOn(console, "log");
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
hyperframesSkillNames,
|
||||
isCoreSkill,
|
||||
presentSkills,
|
||||
pruneOrphanedLockEntries,
|
||||
SKILLS_CLI_LOCK_PATHS_VERIFIED_AT,
|
||||
type SkillDiff,
|
||||
type SkillsCheckResult,
|
||||
@@ -307,8 +308,29 @@ export async function updateSkills(
|
||||
|
||||
let check: SkillsCheckResult | null = null;
|
||||
try {
|
||||
check = await checkSkills({ cwd: opts.cwd });
|
||||
} catch {
|
||||
// `canonical: true` — target selection must match what `skills add`
|
||||
// actually installs from (the canonical published repo), never a local
|
||||
// checkout's `skills-manifest.json`. Without this, running from inside a
|
||||
// stale hyperframes checkout could resolve "latest" from that stale local
|
||||
// file, which may still list a skill that's since been retired/renamed
|
||||
// upstream. `isCoreSkill` would then force it into `targets`/`toInstall`,
|
||||
// `skills add` would correctly (and silently) decline to install a skill
|
||||
// that no longer exists, and verifyInstalled would strict-throw on a
|
||||
// "failure" that was never real. Resolving canonically means a retired
|
||||
// skill simply never appears as a target in the first place.
|
||||
check = await checkSkills({ cwd: opts.cwd, canonical: true });
|
||||
} catch (err) {
|
||||
// A *malformed* canonical manifest (the server was reached, but served a
|
||||
// bad shape) is otherwise indistinguishable from being offline — both fall
|
||||
// through to presence-only mode below. Surface it distinctly so ops can
|
||||
// tell an upstream/CDN problem apart from a genuine network failure.
|
||||
if (err instanceof Error && err.message.startsWith("Malformed skills manifest")) {
|
||||
clack.log.warn(
|
||||
c.warn(
|
||||
"Canonical skills manifest was malformed — falling back to presence-only mode (an upstream/CDN issue, not your network).",
|
||||
),
|
||||
);
|
||||
}
|
||||
check = null; // manifest unreachable (offline / rate-limited) — presence mode below
|
||||
}
|
||||
if (!check) return updateSkillsOffline(requested, { strict, cwd: opts.cwd });
|
||||
@@ -687,6 +709,24 @@ const updateCommand = defineCommand({
|
||||
c.dim(`Removing ${removed.length} skill(s) no longer published: ${removed.join(", ")}`),
|
||||
);
|
||||
await runSkillsRemove(removed, { global: scope === "global" });
|
||||
// Self-heal: `skills remove` only clears a lock entry for a name it
|
||||
// found an on-disk bundle for (see pruneOrphanedLockEntries). A skill
|
||||
// retired before it ever shipped a bundle to this machine has none, so
|
||||
// the call above is a silent no-op for it — the lock entry lingers and
|
||||
// would be re-flagged "removed" on every future run. Prune whatever is
|
||||
// still attributed after the call so `check || update` converges
|
||||
// instead of looping forever. Best-effort and scoped to exactly the
|
||||
// lock the remove above targeted (same `scope`); a write failure here
|
||||
// must not fail the update — the install already succeeded.
|
||||
const scopeForPrune = scope ?? "global";
|
||||
const stillOrphaned = pruneOrphanedLockEntries(removed, scopeForPrune);
|
||||
if (stillOrphaned.length) {
|
||||
console.log(
|
||||
c.dim(
|
||||
`Reconciled ${stillOrphaned.length} orphaned lock entr${stillOrphaned.length === 1 ? "y" : "ies"} with no on-disk bundle: ${stillOrphaned.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clack.log.warn(c.warn(`Skipped removed-skill cleanup: ${(err as Error).message}`));
|
||||
|
||||
Reference in New Issue
Block a user