mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: media-use bug-bash fixes (codex gate, id race, provider/reuse/adopt guards) + CLI unknown-flag rejection (#2033)
* fix(media-use): codex gate misfires as 'not logged in' when piped codexUnavailableReason() gated generation on parsing `codex login status` stdout, but that command prints 'Logged in using ChatGPT' to stderr and exits 0 — so the piped stdout media-use captures (execFileSync returns stdout only on success) was empty, and the gate falsely reported 'not logged in'. Every headless / CI / agent run was blocked from codex image gen even when fully authed. Gate on the durable credentials file ($CODEX_HOME/auth.json) instead of the TTY/stderr-only human text. Token validity is still proven by the exec, which fails cleanly on a stale login. The stdout `features list` capability check is unchanged. Verified: reproduced the false 'not logged in' block, then after the fix generated end-to-end via `resolve -t image --provider codex` (valid 1254x1254 PNG, source=generated, provider=codex.image_gen). * fix(media-use): bug-bash fixes — id race, provider/reuse/adopt guards From the bug-bash against main: - MU-23 (HIGH): concurrent resolves raced on nextId (read-max-then-append, non-atomic), so parallel agents got duplicate ids and clobbered each other's files. Add allocateId(): a coarse per-project lock (.media/.lock, 15s stale-steal) around id allocation that scans the manifest AND the type dir for reserved ids, then O_EXCL-creates a placeholder file so the slow download between allocate and append can't collide. 5 parallel resolves now yield 5 distinct ids + files. - X4: --reuse imported across a type mismatch (bgm asset under images/). Apply typesMatch on the --reuse path; reject mismatches (icon<->image still interchangeable). - X5: --provider silently overrode --local-only and made a network call. --local-only is now a hard guard: network providers are skipped even under a forced provider; the miss message explains the conflict. - BUG-2: --provider ignored the exact-cache floor and could hand back an asset from a different provider. A forced --provider now bypasses all reuse rungs (regenerate with THIS provider); the unforced floor is intact. - MU-26/X6: 0-byte assets accepted. --adopt skips 0-byte files (loud); ingest refuses a 0-byte local file (freezeUrl already rejects empty responses). - BUG-4: unknown/unavailable --provider now errors with the available list instead of a generic 'no provider could resolve' (typo != catalog miss). - BUG-5: --reuse "" gave the wrong 'type and intent required' error; it now routes to a clear empty-sha message. - BUG-3: voice duration leaked an unrounded float into index.md; round all durations to 0.1s centrally at record build (matches probe). - Nits: whitespace-only --intent is rejected; nudge grammar (exists/exist). Tests: allocateId reservation + registry local-only-wins added; full media-use suite green. All fixes verified e2e. * fix(cli): reject unknown flags instead of silently ignoring them citty is permissive: an unrecognized flag was dropped, not rejected — so `render . --out x` (the flag is --output/-o) silently ignored --out and rendered to the default renders/<name>.mp4 path. A mistyped flag read as a render/catalog miss. Add assertKnownFlags(): validate every dash-prefixed token against the command's declared args + aliases + the global set (help/version/json) before the command runs, in the shared trackCommandFailures run-wrapper so every leaf command is covered. Handles --flag=value, --no-<bool> negation, camelCase<->kebab arg names, and combined shorts; stops at --; positionals and flag values pass through. Verified: `render . --out x` -> 'Error: Unknown flag: --out'; --output/-o/ --json/--help still accepted. Unit tests added. * docs(skills): install with --full-depth so agents get current main The documented `npx skills add heygen-com/hyperframes` fetched the skills.sh registry blob, which lags GitHub main by hours — so users following the docs got a stale skill (e.g. media-use v1: no --candidates, voice stubbed). The CLI's own `hyperframes skills` command already forces a full clone via --full-depth to bypass this; the docs didn't pass it. Add --full-depth to every documented install command (README, CLAUDE.md, docs/guides/skills.mdx) with a one-line note on the lag. Addresses the user-facing half of the publish/registry lag (#2034). * chore(media-use): collapse resolve.mjs import to satisfy oxfmt --check * fix(cli): extract longFlagName to keep flag validator under complexity gate Also regenerate skills-manifest.json (resolve.mjs formatting change re-hashed the media-use skill). Fixes the Fallow audit + skills-manifest-in-sync CI gates.
This commit is contained in:
@@ -7,11 +7,13 @@ Open-source video rendering framework: write HTML, render video.
|
||||
This repo ships 20 AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover.
|
||||
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes # interactive picker
|
||||
npx skills add heygen-com/hyperframes --all # install all 20 (skips picker)
|
||||
npx skills add heygen-com/hyperframes --skill <name> # just one (bare name, no leading slash)
|
||||
npx skills add heygen-com/hyperframes --full-depth # interactive picker
|
||||
npx skills add heygen-com/hyperframes --all --full-depth # install all 20 (skips picker)
|
||||
npx skills add heygen-com/hyperframes --skill <name> --full-depth # just one (bare name, no leading slash)
|
||||
```
|
||||
|
||||
Keep `--full-depth`: it installs the current `main`. Without it, `skills add` fetches the skills.sh registry blob, which lags `main` by hours (you'd get a stale skill). `hyperframes skills update` already uses full-depth.
|
||||
|
||||
**`/hyperframes` is the entry skill — read it first.** It's the capability map for the domain skills below AND the intent router for the creation workflows. The full README skills section mirrors this list; keep them in sync (see "Skill catalog maintenance" below).
|
||||
|
||||
### Creation workflows
|
||||
|
||||
@@ -38,9 +38,11 @@ HyperFrames is an open-source framework for turning HTML, CSS, media, and seekab
|
||||
Install the HyperFrames skills, then describe the video you want:
|
||||
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes --yes
|
||||
npx skills add heygen-com/hyperframes --full-depth --yes
|
||||
```
|
||||
|
||||
> `--full-depth` does a full clone of the repo's current `main`. Without it, `skills add` fetches the skills.sh registry blob, which lags `main` by hours — you'd get an older copy of a skill. (`hyperframes skills update` already installs full-depth.)
|
||||
|
||||
Try a prompt like:
|
||||
|
||||
> Using `/hyperframes`, create a 10-second product intro with a fade-in title, a background video, and subtle background music.
|
||||
@@ -51,7 +53,7 @@ The skills teach agents the HyperFrames production loop: plan the video, write v
|
||||
|
||||
HyperFrames ships 20 skills agents load on demand. Read `/hyperframes` first — it's the router and capability map; it picks a workflow for any "make me a…" request — video, deck, or composition port — and points to the domain skills below.
|
||||
|
||||
Run `npx skills add heygen-com/hyperframes` for the interactive picker, `npx skills add heygen-com/hyperframes --all` to install all 20 at once (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).
|
||||
Run `npx skills add heygen-com/hyperframes --full-depth` for the interactive picker, `npx skills add heygen-com/hyperframes --all --full-depth` to install all 20 at once (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name> --full-depth` for just one (bare name, no leading `/`). Keep `--full-depth` — it installs the current `main`; without it `skills add` fetches the skills.sh blob, which lags by hours.
|
||||
|
||||
### Router
|
||||
|
||||
|
||||
@@ -16,21 +16,21 @@ The skills split into three groups:
|
||||
<Steps>
|
||||
<Step title="Pick what to install (interactive picker)">
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes
|
||||
npx skills add heygen-com/hyperframes --full-depth
|
||||
```
|
||||
|
||||
Opens a picker so you can choose which skills to add. Works with [Claude Code](https://claude.ai/claude-code), [Cursor](https://cursor.sh), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex CLI](https://github.com/openai/codex), [GitHub Copilot CLI](/guides/copilot-cli), and [Google Antigravity](/guides/antigravity).
|
||||
Opens a picker so you can choose which skills to add. Keep `--full-depth`: it installs the current `main`. Without it, `skills add` fetches the skills.sh registry blob, which lags `main` by hours, so you may get an older copy of a skill. Works with [Claude Code](https://claude.ai/claude-code), [Cursor](https://cursor.sh), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex CLI](https://github.com/openai/codex), [GitHub Copilot CLI](/guides/copilot-cli), and [Google Antigravity](/guides/antigravity).
|
||||
</Step>
|
||||
<Step title="Or install everything at once (skip the picker)">
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes --all
|
||||
npx skills add heygen-com/hyperframes --all --full-depth
|
||||
```
|
||||
|
||||
Writes every skill to your project in one shot. Recommended when you want the full set without selecting from the picker.
|
||||
</Step>
|
||||
<Step title="Or install one skill at a time">
|
||||
```bash
|
||||
npx skills add heygen-com/hyperframes --skill <name>
|
||||
npx skills add heygen-com/hyperframes --skill <name> --full-depth
|
||||
```
|
||||
|
||||
Pass the bare skill name (no leading `/`) — e.g. `--skill hyperframes-animation`. Useful when you want a single capability without the full set.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CommandDef } from "citty";
|
||||
import { assertKnownFlags } from "./reject-unknown-flags.js";
|
||||
|
||||
// citty types subcommands as `CommandDef<any>` (SubCommandsDef); mirror that so
|
||||
// each command's specific args type is accepted without per-command generics.
|
||||
@@ -29,6 +30,11 @@ export function trackCommandFailures(
|
||||
return {
|
||||
...cmd,
|
||||
run: async (ctx: Parameters<typeof run>[0]) => {
|
||||
// Reject unknown flags before the command runs: citty silently ignores
|
||||
// them otherwise, dropping the value (e.g. `render --out x` fell back
|
||||
// to the default output path). A leaf command with a `run` is the right
|
||||
// place — nested command groups delegate to their own subcommands.
|
||||
assertKnownFlags(cmd, ctx?.rawArgs ?? []);
|
||||
try {
|
||||
return await run(ctx);
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ArgsDef, CommandDef } from "citty";
|
||||
import { assertKnownFlags } from "./reject-unknown-flags.js";
|
||||
|
||||
const cmd = {
|
||||
args: {
|
||||
output: { type: "string", alias: "o" },
|
||||
gifLoop: { type: "string" },
|
||||
docker: { type: "boolean" },
|
||||
workers: { type: "string", alias: ["w"] },
|
||||
},
|
||||
} as unknown as CommandDef<ArgsDef>;
|
||||
|
||||
const ok = (raw: string[]) => () => assertKnownFlags(cmd, raw);
|
||||
|
||||
describe("assertKnownFlags", () => {
|
||||
it("accepts known long and short flags, positionals, and values", () => {
|
||||
expect(ok(["."])).not.toThrow();
|
||||
expect(ok([".", "--output", "out.mp4"])).not.toThrow();
|
||||
expect(ok([".", "-o", "out.mp4"])).not.toThrow();
|
||||
expect(ok(["--output=out.mp4"])).not.toThrow();
|
||||
expect(ok(["--workers", "6", "-w", "6"])).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an unknown long flag (the --out bug)", () => {
|
||||
expect(ok([".", "--out", "out.mp4"])).toThrow(/Unknown flag: --out/);
|
||||
});
|
||||
|
||||
it("rejects an unknown short flag", () => {
|
||||
expect(ok(["-z"])).toThrow(/Unknown flag: -z/);
|
||||
});
|
||||
|
||||
it("matches camelCase args by their kebab-case flag spelling", () => {
|
||||
expect(ok(["--gif-loop", "0"])).not.toThrow();
|
||||
expect(ok(["--gifLoop", "0"])).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts --no-<boolean> negation", () => {
|
||||
expect(ok(["--no-docker"])).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts global flags and stops at --", () => {
|
||||
expect(ok(["--help"])).not.toThrow();
|
||||
expect(ok(["--json"])).not.toThrow();
|
||||
expect(ok(["--", "--anything-goes-here"])).not.toThrow();
|
||||
});
|
||||
|
||||
it("checks each char of a combined short group", () => {
|
||||
expect(ok(["-ow"])).not.toThrow(); // both known aliases
|
||||
expect(ok(["-ox"])).toThrow(/Unknown flag: -x/); // x unknown
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ArgsDef, CommandDef } from "citty";
|
||||
|
||||
// citty is permissive: an unrecognized flag (e.g. `render --out x` when the flag
|
||||
// is `--output`/`-o`) is silently ignored instead of rejected, so the value is
|
||||
// dropped and the command falls back to its default — a silent wrong result. We
|
||||
// reject unknown flags up front with a clear message.
|
||||
|
||||
// Global flags citty / the CLI understand on every command.
|
||||
const ALWAYS_KNOWN = new Set(["help", "h", "version", "v", "json"]);
|
||||
|
||||
// A camelCase arg name (`gifLoop`) is passed as `--gif-loop`; a kebab name is
|
||||
// passed as-is. Accept both spellings so the validator matches citty's parsing.
|
||||
function nameVariants(name: string): string[] {
|
||||
const kebab = name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
|
||||
const camel = name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
return [name, kebab, camel];
|
||||
}
|
||||
|
||||
function knownFlags(args: ArgsDef | undefined): Set<string> {
|
||||
const known = new Set(ALWAYS_KNOWN);
|
||||
for (const [name, def] of Object.entries(args ?? {})) {
|
||||
for (const v of nameVariants(name)) known.add(v);
|
||||
const alias = (def as { alias?: string | string[] })?.alias;
|
||||
if (typeof alias === "string") known.add(alias);
|
||||
else if (Array.isArray(alias)) for (const a of alias) known.add(a);
|
||||
}
|
||||
return known;
|
||||
}
|
||||
|
||||
// The unknown flag a single token introduces, or null when it's fine
|
||||
// (positional, flag value, `--`, or all-known). `--no-foo` -> `foo`,
|
||||
// `--flag=value` -> `flag`; a combined short group (`-ab`) checks each char.
|
||||
// `--flag`, `--flag=value`, `--no-flag` -> the bare flag name.
|
||||
function longFlagName(tok: string): string {
|
||||
const name = tok.slice(2).split("=")[0] ?? "";
|
||||
return name.startsWith("no-") ? name.slice(3) : name;
|
||||
}
|
||||
|
||||
function unknownFlagIn(tok: string, known: Set<string>): string | null {
|
||||
if (tok === "-" || !tok.startsWith("-")) return null; // positional or flag value
|
||||
if (tok.startsWith("--")) {
|
||||
const name = longFlagName(tok);
|
||||
return name && !known.has(name) ? `--${name}` : null;
|
||||
}
|
||||
for (const ch of tok.slice(1).split("=")[0] ?? "") {
|
||||
if (!known.has(ch)) return `-${ch}`; // combined shorts: check each char
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw on the first flag in `rawArgs` not declared by `cmd` (its args + aliases
|
||||
* + the global set). Only dash-prefixed tokens are inspected, so positionals and
|
||||
* flag values pass through untouched. Stops at `--`.
|
||||
*/
|
||||
export function assertKnownFlags(cmd: CommandDef<ArgsDef>, rawArgs: string[]): void {
|
||||
if (!Array.isArray(rawArgs)) return;
|
||||
// citty types `args` as Resolvable<ArgsDef> (it may be a fn/promise); every
|
||||
// hyperframes command uses a static object, so treat anything else as "no
|
||||
// declared args" and skip validation rather than risk a wrong rejection.
|
||||
const rawDef = cmd.args;
|
||||
const args = rawDef && typeof rawDef === "object" ? (rawDef as ArgsDef) : undefined;
|
||||
const known = knownFlags(args);
|
||||
for (const tok of rawArgs) {
|
||||
if (tok === "--") break;
|
||||
const bad = unknownFlagIn(tok, known);
|
||||
if (bad) throw new Error(`Unknown flag: ${bad}`);
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
"files": 10
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "49352d2f3b42c0ba",
|
||||
"hash": "ffd883ca5d722a3b",
|
||||
"files": 101
|
||||
},
|
||||
"motion-graphics": {
|
||||
|
||||
@@ -54,6 +54,12 @@ export function scanExistingAssets(projectDir) {
|
||||
if (!type) continue;
|
||||
const fullPath = join(assetsDir, rel);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.size === 0) {
|
||||
// A 0-byte asset would register clean but fail at render — skip it loudly
|
||||
// rather than adopt a broken file.
|
||||
console.error(`media-use: skipping 0-byte asset assets/${rel}`);
|
||||
continue;
|
||||
}
|
||||
const meta = probe(fullPath);
|
||||
found.push({
|
||||
relativePath: `assets/${rel}`,
|
||||
|
||||
@@ -64,8 +64,15 @@ function codexUnavailableReason() {
|
||||
} catch {
|
||||
return "codex CLI not on PATH";
|
||||
}
|
||||
const login = codexRun(["login", "status"]);
|
||||
if (!login || !/logged in/i.test(login)) return "codex not logged in (run: codex login)";
|
||||
// Auth marker: presence of the credentials file, NOT `codex login status`.
|
||||
// That command prints "Logged in using ChatGPT" only to a human stream
|
||||
// (stderr / TTY) and exits 0, so its piped stdout — how media-use spawns it —
|
||||
// is empty, and the gate falsely reported "not logged in", blocking codex
|
||||
// image gen in every headless / CI / agent run even when fully authed.
|
||||
// auth.json is the durable, TTY-independent signal; token validity is proven
|
||||
// by the exec itself, which fails cleanly if the login is stale.
|
||||
const authPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json");
|
||||
if (!existsSync(authPath)) return "codex not logged in (run: codex login)";
|
||||
const feats = codexRun(["features", "list"]);
|
||||
if (feats == null) return "could not read `codex features list`";
|
||||
if (!/\bimage_generation\b/.test(feats)) return "codex image_generation feature unavailable";
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { readFileSync, appendFileSync, mkdirSync, existsSync } from "node:fs";
|
||||
import {
|
||||
readFileSync,
|
||||
appendFileSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
readdirSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
writeFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const MANIFEST_FILE = "manifest.jsonl";
|
||||
@@ -103,3 +114,75 @@ export function nextId(projectDir, type) {
|
||||
}
|
||||
return `${prefix}_${String(max + 1).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
// Sync sleep (no busy-spin) for the allocation lock retry.
|
||||
function sleepMs(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
// Coarse per-project lock so concurrent resolves don't race on id allocation.
|
||||
// ponytail: one lock file with a 15s stale-steal (a crashed holder can't wedge
|
||||
// the project); fine for agent-scale concurrency — revisit if throughput needs
|
||||
// finer locking. Date.now() is available here (a normal Node CLI, not a
|
||||
// workflow DSL), so mtime-based staleness is safe.
|
||||
const LOCK_STALE_MS = 15000;
|
||||
const LOCK_TIMEOUT_MS = 20000;
|
||||
|
||||
function withLock(dir, fn) {
|
||||
const lock = join(dir, ".lock");
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
closeSync(openSync(lock, "wx")); // O_EXCL: atomic acquire
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") throw err;
|
||||
try {
|
||||
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
|
||||
rmSync(lock, { force: true }); // steal a stale lock from a dead holder
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue; // lock vanished between check and stat — retry the acquire
|
||||
}
|
||||
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
||||
throw new Error("media-use: timed out acquiring .media/.lock");
|
||||
}
|
||||
sleepMs(25);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
rmSync(lock, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Atomically allocate the next free id for `type` AND reserve its file, so a
|
||||
// slow download/copy between allocation and appendRecord can't let a concurrent
|
||||
// caller grab the same id (the MU-23 clobber). Under the lock we take the max id
|
||||
// across BOTH the manifest and any already-reserved files in the type dir, then
|
||||
// O_EXCL-create an empty placeholder at the target path; freeze/copy overwrites
|
||||
// it. Returns { id, localPath }.
|
||||
export function allocateId(projectDir, type, ext) {
|
||||
mkdirSync(mediaDir(projectDir), { recursive: true });
|
||||
const typeDir = typeDirPath(projectDir, type);
|
||||
mkdirSync(typeDir, { recursive: true });
|
||||
return withLock(mediaDir(projectDir), () => {
|
||||
const re = new RegExp(`^${type}_(\\d+)`);
|
||||
let max = 0;
|
||||
for (const r of readManifest(projectDir)) {
|
||||
if (r.type !== type) continue;
|
||||
const m = r.id?.match(re);
|
||||
if (m) max = Math.max(max, parseInt(m[1], 10));
|
||||
}
|
||||
for (const f of readdirSync(typeDir)) {
|
||||
const m = f.match(re);
|
||||
if (m) max = Math.max(max, parseInt(m[1], 10)); // skip ids reserved but not yet appended
|
||||
}
|
||||
const id = `${type}_${String(max + 1).padStart(3, "0")}`;
|
||||
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
|
||||
writeFileSync(join(projectDir, localPath), "", { flag: "wx" }); // durable reservation
|
||||
return { id, localPath };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
findByPrompt,
|
||||
findByEntity,
|
||||
nextId,
|
||||
allocateId,
|
||||
normalizePrompt,
|
||||
manifestPath,
|
||||
mediaDir,
|
||||
@@ -129,6 +130,28 @@ function runTests() {
|
||||
assert.equal(normalizePrompt(null), "");
|
||||
});
|
||||
|
||||
test("allocateId reserves the id on disk so a pre-append caller can't reuse it (MU-23)", () => {
|
||||
setup();
|
||||
const a = allocateId(tmp, "bgm", ".wav");
|
||||
assert.equal(a.id, "bgm_001");
|
||||
assert.ok(existsSync(join(tmp, a.localPath)), "placeholder reserved on disk");
|
||||
// Second allocation BEFORE any manifest append (the download window) must not
|
||||
// hand back bgm_001 again, even with a different extension.
|
||||
const b = allocateId(tmp, "bgm", ".mp3");
|
||||
assert.equal(b.id, "bgm_002");
|
||||
assert.notEqual(a.localPath, b.localPath);
|
||||
// Lock file is released (not left behind).
|
||||
assert.ok(!existsSync(join(tmp, ".media", ".lock")), "lock released");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("allocateId continues past the highest manifest id", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord({ id: "bgm_005" }));
|
||||
assert.equal(allocateId(tmp, "bgm", ".wav").id, "bgm_006");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("findByEntity matches case-insensitively", () => {
|
||||
setup();
|
||||
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));
|
||||
|
||||
@@ -79,6 +79,20 @@ export function listTypes() {
|
||||
return Object.keys(REGISTRY);
|
||||
}
|
||||
|
||||
/** Provider names available for a type, in cascade order (for --provider validation). */
|
||||
export function providerNamesFor(type) {
|
||||
return listFor(type).map((p) => p.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does an override token (full name like "codex.image_gen" or a prefix like
|
||||
* "codex") match any provider declared for the type? Same match rule as
|
||||
* runProviders, so validation and dispatch never disagree.
|
||||
*/
|
||||
export function providerMatches(type, want) {
|
||||
return providerNamesFor(type).some((n) => n === want || n.startsWith(`${want}.`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-compat shim for the v1 single-provider API. Returns the first declared
|
||||
* provider for the type (tagged with `type`); throws for an unknown type.
|
||||
@@ -94,17 +108,21 @@ export function getProvider(type) {
|
||||
* order, returns the first non-null result, skips providers that don't expose
|
||||
* the capability. Pure over its input — the unit-testable core of the cascade.
|
||||
*
|
||||
* Offline guard: a `network` provider is skipped when `ctx.localOnly` is set.
|
||||
* Offline guard: a `network` provider is skipped when `ctx.localOnly` is set —
|
||||
* unconditionally, even under a `ctx.provider` override. --local-only is a hard
|
||||
* safety flag: it must never make a network call. Forcing a network provider
|
||||
* while offline yields a clean miss (the caller explains the conflict), never a
|
||||
* silent network request.
|
||||
* Provider override: `ctx.provider` (a full name like "codex.image_gen" or a
|
||||
* prefix like "codex") pins resolution to matching providers only — this is how
|
||||
* a user "make an image WITH codex" forces the upsell instead of taking the
|
||||
* free-first default. An override to a `network` provider ignores --local-only.
|
||||
* free-first default.
|
||||
*/
|
||||
export async function runProviders(providers, capability, intent, ctx) {
|
||||
const want = ctx?.provider;
|
||||
for (const p of providers) {
|
||||
if (want && p.name !== want && !p.name.startsWith(`${want}.`)) continue;
|
||||
if (p.network && ctx?.localOnly && !want) continue; // --local-only: cache + local only
|
||||
if (p.network && ctx?.localOnly) continue; // --local-only wins, even over --provider
|
||||
const fn = p[capability];
|
||||
if (typeof fn !== "function") continue;
|
||||
const res = await fn(intent, ctx);
|
||||
|
||||
@@ -68,10 +68,16 @@ test("ctx.provider forces one generator (e.g. 'make an image WITH codex')", asyn
|
||||
await runProviders(providers, "generate", "x", { provider: "codex.image_gen" }),
|
||||
{ hit: "codex" },
|
||||
);
|
||||
// forcing a network provider ignores --local-only (explicit user intent)
|
||||
assert.deepEqual(
|
||||
// --local-only wins even over a forced network provider: no network call,
|
||||
// clean miss (the caller surfaces the conflict). A forced LOCAL provider under
|
||||
// --local-only still runs.
|
||||
assert.equal(
|
||||
await runProviders(providers, "generate", "x", { provider: "codex", localOnly: true }),
|
||||
{ hit: "codex" },
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await runProviders(providers, "generate", "x", { provider: "mflux", localOnly: true }),
|
||||
{ hit: "local" },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { resolve, join, extname, basename } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { appendRecord, findByPrompt, findByEntity, nextId, typeSubdir } from "./lib/manifest.mjs";
|
||||
import { appendRecord, findByPrompt, findByEntity, nextId, allocateId } from "./lib/manifest.mjs";
|
||||
import { regenerateIndex } from "./lib/index-gen.mjs";
|
||||
import { cacheGet, cacheGetByEntity, importFromCache, cachePut } from "./lib/cache.mjs";
|
||||
import { runCapability, listTypes } from "./lib/registry.mjs";
|
||||
import { runCapability, listTypes, providerMatches, providerNamesFor } from "./lib/registry.mjs";
|
||||
import { freezeUrl, freezeLocalFile, isDirectMediaUrl } from "./lib/freeze.mjs";
|
||||
import { findExistingAsset } from "./lib/adopt.mjs";
|
||||
import { track } from "./lib/telemetry.mjs";
|
||||
@@ -81,8 +81,10 @@ if (args.candidates || args["dry-run"]) {
|
||||
}
|
||||
|
||||
// Reuse: import a specific global-cache asset (by content sha/prefix, taken
|
||||
// from --candidates) into this project.
|
||||
if (args.reuse) {
|
||||
// from --candidates) into this project. `!== undefined` so an empty --reuse ""
|
||||
// still routes here (and gets a clear empty-sha error) instead of falling
|
||||
// through to the misleading "--type and --intent are required".
|
||||
if (args.reuse !== undefined) {
|
||||
await reuseGlobal(args.reuse);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -93,8 +95,8 @@ if (args.from) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!args.type || !args.intent) {
|
||||
console.error("error: --type and --intent are required");
|
||||
if (!args.type || !args.intent || !args.intent.trim()) {
|
||||
console.error("error: --type and a non-empty --intent are required");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -103,14 +105,30 @@ if (!listTypes().includes(args.type)) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Forced-provider validation: reject an unknown/unavailable provider name up
|
||||
// front so a typo reads as a typo, not a catalog miss (`no provider could
|
||||
// resolve`). Match rule mirrors runProviders (full name or dotted prefix).
|
||||
if (args.provider && !providerMatches(args.type, args.provider)) {
|
||||
console.error(
|
||||
`error: unknown provider "${args.provider}" for type ${args.type} (available: ${providerNamesFor(args.type).join(", ")})`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const projectDir = resolve(args.project);
|
||||
const type = args.type;
|
||||
const intent = args.intent;
|
||||
const entity = args.entity || null;
|
||||
|
||||
async function run() {
|
||||
// A forced --provider means "(re)generate with THIS provider" — it bypasses
|
||||
// every reuse rung (project/entity/assets/global cache) so it can't silently
|
||||
// hand back an asset from a different provider. The floor only applies to the
|
||||
// default (unforced) cascade.
|
||||
const forced = !!args.provider;
|
||||
|
||||
// 1. project manifest — exact-prompt match
|
||||
const projectHit = findByPrompt(projectDir, intent, type);
|
||||
const projectHit = forced ? null : findByPrompt(projectDir, intent, type);
|
||||
if (projectHit && existsSync(join(projectDir, projectHit.path))) {
|
||||
return result(projectHit, "cached");
|
||||
}
|
||||
@@ -118,7 +136,7 @@ async function run() {
|
||||
// 1b. entity match in project. icon and image are interchangeable for
|
||||
// entity hits — both live in images/, and figma-imported brand marks are
|
||||
// always recorded as type image while agents ask for logos as type icon.
|
||||
if (entity) {
|
||||
if (!forced && entity) {
|
||||
const entityHit = findByEntity(projectDir, entity);
|
||||
if (
|
||||
entityHit &&
|
||||
@@ -130,7 +148,7 @@ async function run() {
|
||||
}
|
||||
|
||||
// 1c. scan existing assets/ directory for unregistered matches
|
||||
const existingAsset = findExistingAsset(projectDir, intent, type);
|
||||
const existingAsset = forced ? null : findExistingAsset(projectDir, intent, type);
|
||||
if (existingAsset) {
|
||||
const id = nextId(projectDir, type);
|
||||
const record = {
|
||||
@@ -147,11 +165,10 @@ async function run() {
|
||||
}
|
||||
|
||||
// 2. global cache — exact-prompt or entity match
|
||||
const cacheHit = cacheGet(intent, type);
|
||||
const cacheHit = forced ? null : cacheGet(intent, type);
|
||||
if (cacheHit) {
|
||||
const id = nextId(projectDir, type);
|
||||
const ext = extname(cacheHit.cached_path);
|
||||
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
|
||||
const { id, localPath } = allocateId(projectDir, type, ext);
|
||||
const imported = importFromCache(cacheHit, projectDir, id, localPath);
|
||||
if (imported) {
|
||||
appendRecord(projectDir, imported);
|
||||
@@ -160,12 +177,11 @@ async function run() {
|
||||
}
|
||||
}
|
||||
|
||||
if (entity) {
|
||||
if (!forced && entity) {
|
||||
const entityCacheHit = cacheGetByEntity(entity);
|
||||
if (entityCacheHit && typesMatch(entityCacheHit.type, type)) {
|
||||
const id = nextId(projectDir, type);
|
||||
const ext = extname(entityCacheHit.cached_path);
|
||||
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
|
||||
const { id, localPath } = allocateId(projectDir, type, ext);
|
||||
const imported = importFromCache(entityCacheHit, projectDir, id, localPath);
|
||||
if (imported) {
|
||||
appendRecord(projectDir, imported);
|
||||
@@ -189,7 +205,7 @@ async function run() {
|
||||
const { similar } = listCandidates({ projectDir, type, intent, cap: CANDIDATE_CAP });
|
||||
if (similar > 0) {
|
||||
console.error(
|
||||
`media-use: ${similar} similar cached asset${similar === 1 ? "" : "s"} already exist — run \`resolve --candidates --type ${type} --intent "${intent}"\` to review and reuse instead of fetching.`,
|
||||
`media-use: ${similar} similar cached asset${similar === 1 ? "" : "s"} already ${similar === 1 ? "exists" : "exist"} — run \`resolve --candidates --type ${type} --intent "${intent}"\` to review and reuse instead of fetching.`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -224,7 +240,9 @@ async function run() {
|
||||
const msg =
|
||||
type === "brand"
|
||||
? "no brand spec found — add a frame.md or design.md (colors/font/logo) to this project. Run the HyperFrames design flow to create one; brand tokens are read locally for deterministic rendering."
|
||||
: `no provider could resolve ${type}: "${intent}"`;
|
||||
: args.provider
|
||||
? `provider "${args.provider}" could not resolve ${type}: "${intent}"${localOnly ? " (--local-only skips network providers; drop it or the --provider override)" : ""}`
|
||||
: `no provider could resolve ${type}: "${intent}"`;
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: msg }));
|
||||
} else {
|
||||
@@ -233,10 +251,10 @@ async function run() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 5. freeze + register
|
||||
const id = nextId(projectDir, type);
|
||||
// 5. freeze + register (atomic id+file reservation so concurrent resolves
|
||||
// can't collide on an id during the download — MU-23)
|
||||
const ext = searchResult.ext || extFromUrl(searchResult.url || "") || defaultExt(type);
|
||||
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
|
||||
const { id, localPath } = allocateId(projectDir, type, ext);
|
||||
const fullPath = join(projectDir, localPath);
|
||||
|
||||
if (searchResult.localPath) {
|
||||
@@ -254,7 +272,9 @@ async function run() {
|
||||
path: localPath,
|
||||
source: searchResult.source || "search",
|
||||
description: searchResult.metadata?.description || intent,
|
||||
...(searchResult.metadata?.duration != null && { duration: searchResult.metadata.duration }),
|
||||
...(searchResult.metadata?.duration != null && {
|
||||
duration: Math.round(searchResult.metadata.duration * 10) / 10, // round to 0.1s like probe (voice bypassed it)
|
||||
}),
|
||||
...(searchResult.metadata?.width != null && { width: searchResult.metadata.width }),
|
||||
...(searchResult.metadata?.height != null && { height: searchResult.metadata.height }),
|
||||
...(searchResult.metadata?.transparent != null && {
|
||||
@@ -301,9 +321,14 @@ async function ingest(src) {
|
||||
console.error(`error: file not found: ${src}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const id = nextId(projectDir, type);
|
||||
// Refuse 0-byte input: an empty asset would register clean but fail at render
|
||||
// (freezeUrl already rejects empty responses; this covers local files).
|
||||
if (!isUrl && statSync(resolve(src)).size === 0) {
|
||||
console.error(`error: refusing to ingest a 0-byte file: ${src}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const ext = extname(isUrl ? new URL(src).pathname : src) || defaultExt(type);
|
||||
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
|
||||
const { id, localPath } = allocateId(projectDir, type, ext);
|
||||
const fullPath = join(projectDir, localPath);
|
||||
if (isUrl) await freezeUrl(src, fullPath);
|
||||
else freezeLocalFile(resolve(src), fullPath);
|
||||
@@ -359,6 +384,10 @@ async function reuseGlobal(shaArg) {
|
||||
console.error(`error: --reuse requires --type (one of: ${listTypes().join(", ")})`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (!shaArg || !shaArg.trim()) {
|
||||
console.error("error: --reuse needs a content sha/prefix (from `resolve --candidates`)");
|
||||
process.exit(2);
|
||||
}
|
||||
const rec = findGlobalBySha(shaArg);
|
||||
if (rec && rec.ambiguous) {
|
||||
console.error(
|
||||
@@ -370,9 +399,14 @@ async function reuseGlobal(shaArg) {
|
||||
console.error(`error: no reusable global asset matches sha "${shaArg}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
const id = nextId(projectDir, type);
|
||||
// Type guard: don't import a bgm asset as an image (audio under images/).
|
||||
// icon<->image are interchangeable; everything else must match --type.
|
||||
if (!typesMatch(rec.type, type)) {
|
||||
console.error(`error: sha "${shaArg}" is a ${rec.type} asset, not ${type}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const ext = extname(rec.cached_path || "") || defaultExt(type);
|
||||
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
|
||||
const { id, localPath } = allocateId(projectDir, type, ext);
|
||||
const imported = importFromCache(rec, projectDir, id, localPath);
|
||||
if (!imported) {
|
||||
console.error(`error: cache entry for "${shaArg}" is incomplete or missing on disk`);
|
||||
|
||||
Reference in New Issue
Block a user