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:
WaterrrForever
2026-07-28 19:27:09 +08:00
committed by GitHub
parent 2bb6205177
commit d287e5244c
18 changed files with 298 additions and 23 deletions
+18 -1
View File
@@ -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}`));
+11 -4
View File
@@ -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");
});
});
+8 -2
View File
@@ -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;
@@ -9,6 +9,7 @@ import {
projectConfigPath,
readProjectConfig,
resolveAutoProxy,
seedProjectAuthoringSkill,
writeProjectConfig,
PROJECT_CONFIG_FILENAME,
} from "./projectConfig.js";
@@ -84,6 +85,18 @@ describe("projectConfig", () => {
const result = normalizeConfig({ media: "nope" as unknown as never });
expect(result.media).toEqual({ autoProxy: true });
});
it("preserves a valid authoringSkill slug", () => {
const result = normalizeConfig({ authoringSkill: "product-launch-video" });
expect(result.authoringSkill).toBe("product-launch-video");
});
it("drops an invalid authoringSkill (never reaches telemetry)", () => {
const result = normalizeConfig({
authoringSkill: "Not A Slug!" as unknown as never,
});
expect(result.authoringSkill).toBeUndefined();
});
});
describe("readProjectConfig", () => {
@@ -228,4 +241,127 @@ describe("projectConfig", () => {
}
});
});
describe("seedProjectAuthoringSkill", () => {
it("stamps the owning skill into a fresh project (creates the config)", () => {
const dir = tmp();
try {
seedProjectAuthoringSkill(dir, "faceless-explainer");
expect(loadProjectConfig(dir).authoringSkill).toBe("faceless-explainer");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("preserves other config fields when stamping an existing config", () => {
const dir = tmp();
try {
writeProjectConfig(dir, {
...DEFAULT_PROJECT_CONFIG,
registry: "https://custom.example.com",
});
seedProjectAuthoringSkill(dir, "pr-to-video");
const read = readProjectConfig(dir);
expect(read?.authoringSkill).toBe("pr-to-video");
expect(read?.registry).toBe("https://custom.example.com");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("is seed-once: a later --skill never overwrites the project owner", () => {
const dir = tmp();
try {
seedProjectAuthoringSkill(dir, "product-launch-video");
seedProjectAuthoringSkill(dir, "motion-graphics");
expect(loadProjectConfig(dir).authoringSkill).toBe("product-launch-video");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("ignores an invalid slug and writes nothing", () => {
const dir = tmp();
try {
seedProjectAuthoringSkill(dir, "Not A Slug!");
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The seed is the only writer that touches an existing hyperframes.json,
// which is normally committed — a render must not diff it beyond the one
// key being added. Guards against round-tripping through normalizeConfig.
it("preserves config keys outside the known schema", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify(
{
registry: "https://example.com/my-registry",
myTeamSetting: { reviewer: "wenbo", keep: true },
futureSchemaKey: 42,
},
null,
2,
),
"utf-8",
);
seedProjectAuthoringSkill(dir, "product-launch-video");
const raw = JSON.parse(readFileSync(projectConfigPath(dir), "utf-8"));
expect(raw.authoringSkill).toBe("product-launch-video");
expect(raw.myTeamSetting).toEqual({ reviewer: "wenbo", keep: true });
expect(raw.futureSchemaKey).toBe(42);
expect(raw.registry).toBe("https://example.com/my-registry");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not materialize a media block the user never wrote", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://example.com/r" }, null, 2),
"utf-8",
);
seedProjectAuthoringSkill(dir, "motion-graphics");
const raw = JSON.parse(readFileSync(projectConfigPath(dir), "utf-8"));
expect(raw.media).toBeUndefined();
expect(raw.$schema).toBeUndefined();
expect(Object.keys(raw)).toEqual(["registry", "authoringSkill"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reuses the file's own indentation", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://example.com/r" }, null, 4),
"utf-8",
);
seedProjectAuthoringSkill(dir, "pr-to-video");
expect(readFileSync(projectConfigPath(dir), "utf-8")).toContain('\n "registry"');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("leaves a corrupt config untouched rather than clobbering it", () => {
const dir = tmp();
try {
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
seedProjectAuthoringSkill(dir, "faceless-explainer");
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe("{ not valid json");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
});
+83
View File
@@ -10,6 +10,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { DEFAULT_REGISTRY_URL } from "../registry/index.js";
import { normalizeSkillSlug } from "../telemetry/skill.js";
export const PROJECT_CONFIG_FILENAME = "hyperframes.json";
const PROJECT_CONFIG_SCHEMA_URL = "https://hyperframes.heygen.com/schema/hyperframes.json";
@@ -40,6 +41,14 @@ export interface ProjectConfig {
paths: ProjectConfigPaths;
/** Media handling options (e.g. auto-proxying of browser-hostile codecs). */
media?: ProjectConfigMedia;
/**
* Owning authoring-workflow skill slug (e.g. "product-launch-video"). Stamped
* by `hyperframes init --skill` or seeded from the first `hyperframes render
* --skill`, then read back so every later render of this project — re-render,
* `npm run render`, `--batch`, preview is attributed to it on anonymous
* telemetry without the caller re-passing the flag.
*/
authoringSkill?: string;
}
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
@@ -92,6 +101,9 @@ export function normalizeConfig(partial: Partial<ProjectConfig>): ProjectConfig
? partial.media.autoProxy
: DEFAULT_PROJECT_CONFIG.media?.autoProxy,
},
// Slug-gate on read so a hand-edited or corrupt value never reaches the
// telemetry stream; an invalid slug simply drops the attribution.
authoringSkill: normalizeSkillSlug(partial.authoringSkill),
};
}
@@ -127,3 +139,74 @@ export function resolveAutoProxy(projectDir: string, flagValue: boolean | undefi
}
return loadProjectConfig(projectDir).media?.autoProxy ?? true;
}
/** A parsed JSON value that can carry arbitrary keys — narrowed, not asserted. */
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** True when a thrown filesystem error reports the path as absent. */
function isFileNotFound(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
/**
* Persist the owning authoring-skill slug into `hyperframes.json` so every
* later render of this project re-render, `npm run render`, `--batch`,
* preview is attributed to the workflow that created it, without the caller
* re-passing `--skill`.
*
* Seed-once: an existing stamp is never overwritten (the creating workflow owns
* the identity; a one-off `--skill` on a later render still governs that
* render's telemetry but does not rewrite the project's owner). An invalid or
* empty slug is ignored. Best effort: a read-only or missing project directory
* never fails the render it rode in on.
*
* This 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), so it must not round-trip through {@link normalizeConfig}:
* that rebuilds the object from a field whitelist, which would drop keys it
* does not know about and materialize defaults the user never wrote. The file
* is normally committed, so a render must not introduce a diff beyond the one
* key being added. Patch the parsed JSON in place instead, reusing the file's
* own indentation.
*/
export function seedProjectAuthoringSkill(projectDir: string, rawSkill: unknown): void {
const skill = normalizeSkillSlug(rawSkill);
if (!skill) return;
const path = projectConfigPath(projectDir);
// Read once and branch on why the read failed, rather than testing for the
// file first: an `existsSync`-then-write pair is a check-then-use race, and
// only a genuinely absent config may be created from scratch — any other
// read failure (permissions, I/O) must leave an existing file alone instead
// of overwriting it with a default.
let text: string;
try {
text = readFileSync(path, "utf-8");
} catch (error) {
if (isFileNotFound(error)) {
try {
writeProjectConfig(projectDir, { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill });
} catch {
// Read-only or missing project directory — best effort.
}
}
return;
}
try {
const parsed: unknown = JSON.parse(text);
// A malformed config is left untouched rather than clobbered by a render.
if (!isJsonObject(parsed)) return;
// Seed-once. Normalized so a hand-edited garbage slug neither reaches
// telemetry nor wedges the seed — the next `--skill` render heals it.
if (normalizeSkillSlug(parsed.authoringSkill)) return;
parsed.authoringSkill = skill;
const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " ";
writeFileSync(path, JSON.stringify(parsed, null, indent) + "\n", "utf-8");
} catch {
// Corrupt JSON, or a read-only file: attribution is best-effort telemetry,
// never a render blocker.
}
}