mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(cli): persist authoring skill in hyperframes.json for durable render attribution (#2762)
* fix(cli): persist authoring skill in hyperframes.json for durable render attribution authoring_skill was stamped only on the first render through a workflow passing --skill, so re-renders, `npm run render`, --batch, existing-project renders, and general-video lost it — leaving 77-96% of real-human render volume un-attributed and the skills-penetration metric misleadingly low. Persist the owning skill in hyperframes.json: `init --skill` stamps it at creation, `render` resolves the flag then falls back to the stored value, and an explicit --skill seeds it (seed-once, never overwriting the creating workflow's identity). Activate all render-producing creation workflows to declare their skill at init. Forward-only: does not rewrite historical telemetry. * fix(cli): patch hyperframes.json in place when seeding the authoring skill seedProjectAuthoringSkill is the only writer that touches an already existing hyperframes.json — every other writeProjectConfig call site is guarded to write only when the file is absent, which made the whole-file overwrite safe by construction. Round-tripping the seed through normalizeConfig broke that: it rebuilds the object from a field whitelist with no rest-spread, so any key outside the schema was silently dropped, a media block was materialized in projects that never had one, and key order was rewritten. hyperframes.json is normally committed, so a render introduced a diff the user never asked for, and any field added to the schema later would be deleted by a render on an older CLI. Parse the raw JSON, set authoringSkill, write it back, reusing the file's own indentation. Unknown keys and formatting survive; the only delta is the key being added. A corrupt config is now left untouched instead of clobbered. Seed-once semantics are unchanged, still normalized so a hand-edited garbage slug neither reaches telemetry nor wedges the seed. Reported independently by both reviewers on #2762. * fix(cli): create the docker build context with mkdtempSync The `--docker` build context was created at a guessable path derived from `Date.now()` in the world-writable OS temp dir. Another local user can pre-create or symlink that path and have the build read a Dockerfile they control. mkdtempSync gets a random suffix and 0o700 from the kernel, and it creates the directory itself, so the separate mkdirSync goes away. Pre-existing on main (alert #432, 2026-06-04, packages/cli/src/commands/render.ts), surfaced against this branch only because the seed commit shifted line numbers in the same file. Fixed here to unblock the CodeQL gate on #2762 rather than left for a follow-up; the remaining 10 js/insecure-temporary-file alerts elsewhere in the repo are untouched and still want their own pass. * fix(cli): drop the check-then-use race when seeding the authoring skill The seed tested for the config with existsSync and then wrote, which is a check-then-use race: the file can be created or swapped between the check and the write (CodeQL js/file-system-race). Read once and branch on the failure reason instead. Only ENOENT creates a config from scratch; any other read failure (permissions, I/O) now leaves an existing file alone rather than overwriting it with a default, so this is also strictly safer than the version it replaces. Also replaces the `as Record<string, unknown>` assertion with an isJsonObject type guard, per the repo's no-assertion convention. Behaviour unchanged: all 4 seed regression tests still pass, and the create/preserve/seed-once/corrupt-untouched paths were re-verified end to end.
This commit is contained in:
@@ -556,6 +556,7 @@ async function scaffoldProject(
|
||||
durationSeconds?: number,
|
||||
tailwind = false,
|
||||
resolution?: CanvasResolution,
|
||||
authoringSkill?: string,
|
||||
): Promise<void> {
|
||||
mkdirSync(destDir, { recursive: true });
|
||||
|
||||
@@ -588,10 +589,17 @@ async function scaffoldProject(
|
||||
|
||||
// Write hyperframes.json so `hyperframes add` knows which registry to use
|
||||
// and where to drop block/component files. Overwritten only if absent.
|
||||
// When the scaffolding workflow declared itself via --skill, stamp the owning
|
||||
// skill here so every later render of this project is attributed to it.
|
||||
if (!existsSync(resolve(destDir, "hyperframes.json"))) {
|
||||
const { writeProjectConfig, DEFAULT_PROJECT_CONFIG } =
|
||||
await import("../utils/projectConfig.js");
|
||||
writeProjectConfig(destDir, DEFAULT_PROJECT_CONFIG);
|
||||
const { normalizeSkillSlug } = await import("../telemetry/skill.js");
|
||||
const skill = normalizeSkillSlug(authoringSkill);
|
||||
writeProjectConfig(
|
||||
destDir,
|
||||
skill ? { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill } : DEFAULT_PROJECT_CONFIG,
|
||||
);
|
||||
}
|
||||
|
||||
writeDefaultPackageJson(destDir, name);
|
||||
@@ -728,6 +736,13 @@ export default defineCommand({
|
||||
description:
|
||||
"Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. Default: keep template dimensions (typically 1920x1080).",
|
||||
},
|
||||
skill: {
|
||||
type: "string",
|
||||
description:
|
||||
"Owning authoring workflow slug (e.g. product-launch-video). Stamped into " +
|
||||
"hyperframes.json so every render of this project is attributed to it on " +
|
||||
"anonymous telemetry, without re-passing --skill on each render. Ignored unless it is a slug.",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
if (args.template !== undefined) {
|
||||
@@ -898,6 +913,7 @@ export default defineCommand({
|
||||
videoDuration,
|
||||
tailwind,
|
||||
resolutionPreset,
|
||||
args.skill,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@@ -1112,6 +1128,7 @@ export default defineCommand({
|
||||
videoDuration,
|
||||
tailwind,
|
||||
resolutionPreset,
|
||||
args.skill,
|
||||
);
|
||||
if (!isBundled) {
|
||||
spin.stop(c.success(`Downloaded ${templateId}`));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { failCommand, requestCliExit } from "../utils/commandResult.js";
|
||||
import { defineCommand } from "citty";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js";
|
||||
import { seedProjectAuthoringSkill } from "../utils/projectConfig.js";
|
||||
import { presentRenderPlan } from "./render/present.js";
|
||||
import { executeRenderPlan, renderLintContinuationHint } from "./render/execute.js";
|
||||
// Test-only seams retained at the command boundary for render behavior tests.
|
||||
@@ -351,6 +352,9 @@ export default defineCommand({
|
||||
// Keep the transport adapter thin: each phase has one ownership boundary.
|
||||
async run({ args }) {
|
||||
const plan = createRenderPlan(args);
|
||||
// Teach the project its owning skill from an explicit --skill so every
|
||||
// later flag-less render (re-render, `npm run render`, batch) inherits it.
|
||||
seedProjectAuthoringSkill(plan.project.dir, args.skill);
|
||||
await presentRenderPlan(plan);
|
||||
await executeRenderPlan(plan, {
|
||||
renderDocker,
|
||||
@@ -558,9 +562,12 @@ function ensureDockerImage(version: string, platform: string, quiet: boolean): s
|
||||
|
||||
const dockerfilePath = resolveDockerfilePath();
|
||||
|
||||
// Copy Dockerfile to a temp build context so docker build has a clean context
|
||||
const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
// Copy Dockerfile to a temp build context so docker build has a clean context.
|
||||
// mkdtempSync (not a `Date.now()`-derived name) so the path is unpredictable
|
||||
// and created 0o700 by the kernel — a guessable temp dir in a world-writable
|
||||
// tmpdir is pre-creatable by another local user, who could then swap in their
|
||||
// own Dockerfile or symlink the path (CodeQL js/insecure-temporary-file).
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "hyperframes-docker-"));
|
||||
writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath));
|
||||
|
||||
// Platform is now derived from the host arch (see resolveDockerPlatform).
|
||||
|
||||
@@ -81,4 +81,22 @@ describe("createRenderPlan", () => {
|
||||
const plan = createRenderPlan({ dir: projectDir, "frames-cache-dir": "OFF" });
|
||||
expect(plan.environment.HYPERFRAMES_EXTRACT_CACHE_DIR).toBe("OFF");
|
||||
});
|
||||
|
||||
it("attributes a flag-less render to the skill persisted in hyperframes.json", () => {
|
||||
writeFileSync(
|
||||
join(projectDir, "hyperframes.json"),
|
||||
JSON.stringify({ authoringSkill: "product-launch-video" }),
|
||||
);
|
||||
const plan = createRenderPlan({ dir: projectDir });
|
||||
expect(plan.authoringSkill).toBe("product-launch-video");
|
||||
});
|
||||
|
||||
it("lets an explicit --skill flag override the persisted project owner", () => {
|
||||
writeFileSync(
|
||||
join(projectDir, "hyperframes.json"),
|
||||
JSON.stringify({ authoringSkill: "product-launch-video" }),
|
||||
);
|
||||
const plan = createRenderPlan({ dir: projectDir, skill: "motion-graphics" });
|
||||
expect(plan.authoringSkill).toBe("motion-graphics");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
resolveDefaultFpsArg,
|
||||
} from "../../utils/renderArgs.js";
|
||||
import { normalizeSkillSlug } from "../../telemetry/skill.js";
|
||||
import { loadProjectConfig } from "../../utils/projectConfig.js";
|
||||
|
||||
const VALID_QUALITY = new Set(["draft", "standard", "high"]);
|
||||
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
|
||||
@@ -192,9 +193,14 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
|
||||
}
|
||||
const quality = qualityRaw as RenderQuality;
|
||||
|
||||
const authoringSkill = normalizeSkillSlug(args.skill);
|
||||
// Attribution resolves the explicit --skill flag first, then falls back to
|
||||
// the owning skill persisted in hyperframes.json — so re-renders, batch
|
||||
// renders, and `npm run render` (which never re-pass the flag) stay
|
||||
// attributed to the workflow that created the project.
|
||||
const flagSkill = normalizeSkillSlug(args.skill);
|
||||
const authoringSkill = flagSkill ?? loadProjectConfig(project.dir).authoringSkill;
|
||||
const invalidAuthoringSkill =
|
||||
typeof args.skill === "string" && args.skill.trim() !== "" && !authoringSkill
|
||||
typeof args.skill === "string" && args.skill.trim() !== "" && !flagSkill
|
||||
? args.skill
|
||||
: undefined;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user