From ed1927e26ec7a4b194ced2a1e49134f604cf8a4b Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Fri, 24 Jul 2026 20:52:03 +0800 Subject: [PATCH 1/4] fix(cli): persist authoring skill in hyperframes.json for durable render attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/schema/hyperframes.json | 5 ++ packages/cli/src/commands/init.ts | 19 +++++- packages/cli/src/commands/render.ts | 4 ++ packages/cli/src/commands/render/plan.test.ts | 18 ++++++ packages/cli/src/commands/render/plan.ts | 10 ++- packages/cli/src/utils/projectConfig.test.ts | 62 +++++++++++++++++++ packages/cli/src/utils/projectConfig.ts | 39 ++++++++++++ skills-manifest.json | 16 ++--- skills/embedded-captions/SKILL.md | 2 +- .../references/bespoke-vs-presets.md | 2 +- skills/faceless-explainer/SKILL.md | 2 +- skills/general-video/SKILL.md | 2 +- skills/hyperframes-cli/SKILL.md | 2 + .../references/init-and-scaffold.md | 1 + skills/motion-graphics/SKILL.md | 2 +- skills/music-to-video/SKILL.md | 2 +- skills/pr-to-video/SKILL.md | 2 +- skills/product-launch-video/SKILL.md | 2 +- 18 files changed, 173 insertions(+), 19 deletions(-) diff --git a/docs/schema/hyperframes.json b/docs/schema/hyperframes.json index 70475a8a1c..8821e6631e 100644 --- a/docs/schema/hyperframes.json +++ b/docs/schema/hyperframes.json @@ -51,6 +51,11 @@ "description": "Automatically create H.264 proxies for browser-hostile video codecs on supported preview surfaces. Defaults to true." } } + }, + "authoringSkill": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,63}$", + "description": "Owning authoring-workflow skill slug (e.g. product-launch-video). Set by `hyperframes init --skill` or seeded from the first `hyperframes render --skill`; every render of this project is then attributed to it on anonymous telemetry, without re-passing the flag." } } } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 73faeebc75..46f61885e8 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -556,6 +556,7 @@ async function scaffoldProject( durationSeconds?: number, tailwind = false, resolution?: CanvasResolution, + authoringSkill?: string, ): Promise { 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}`)); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 9b3d067e4c..4d0df774d5 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -3,6 +3,7 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { mkdirSync, 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, diff --git a/packages/cli/src/commands/render/plan.test.ts b/packages/cli/src/commands/render/plan.test.ts index cb4468c302..03482cb0af 100644 --- a/packages/cli/src/commands/render/plan.test.ts +++ b/packages/cli/src/commands/render/plan.test.ts @@ -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"); + }); }); diff --git a/packages/cli/src/commands/render/plan.ts b/packages/cli/src/commands/render/plan.ts index 85ff52290d..043b08db9c 100644 --- a/packages/cli/src/commands/render/plan.ts +++ b/packages/cli/src/commands/render/plan.ts @@ -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; diff --git a/packages/cli/src/utils/projectConfig.test.ts b/packages/cli/src/utils/projectConfig.test.ts index 7e738979b4..793935513e 100644 --- a/packages/cli/src/utils/projectConfig.test.ts +++ b/packages/cli/src/utils/projectConfig.test.ts @@ -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,53 @@ 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 }); + } + }); + }); }); diff --git a/packages/cli/src/utils/projectConfig.ts b/packages/cli/src/utils/projectConfig.ts index e16e72848f..f94b4b68ea 100644 --- a/packages/cli/src/utils/projectConfig.ts +++ b/packages/cli/src/utils/projectConfig.ts @@ -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 ? 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,30 @@ export function resolveAutoProxy(projectDir: string, flagValue: boolean | undefi } return loadProjectConfig(projectDir).media?.autoProxy ?? true; } + +/** + * 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. + */ +export function seedProjectAuthoringSkill(projectDir: string, rawSkill: unknown): void { + const skill = normalizeSkillSlug(rawSkill); + if (!skill) return; + try { + const current = readProjectConfig(projectDir); + if (current?.authoringSkill) return; + writeProjectConfig(projectDir, { + ...(current ?? DEFAULT_PROJECT_CONFIG), + authoringSkill: skill, + }); + } catch { + // Attribution is best-effort telemetry, never a render blocker. + } +} diff --git a/skills-manifest.json b/skills-manifest.json index 4cb98f52e0..6b01754d29 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -2,11 +2,11 @@ "source": "heygen-com/hyperframes", "skills": { "embedded-captions": { - "hash": "a30d5691b4389ec1", + "hash": "ed4dc7b850b92ff5", "files": 140 }, "faceless-explainer": { - "hash": "15fc5a20e2a44bbe", + "hash": "b772a9b6c8118c2c", "files": 22 }, "figma": { @@ -14,7 +14,7 @@ "files": 2 }, "general-video": { - "hash": "e8c377a79bd57a69", + "hash": "87f292b572cad3f9", "files": 4 }, "hyperframes": { @@ -26,7 +26,7 @@ "files": 121 }, "hyperframes-cli": { - "hash": "a4db0693ff88e760", + "hash": "8c791e330873b1bd", "files": 11 }, "hyperframes-core": { @@ -50,19 +50,19 @@ "files": 151 }, "motion-graphics": { - "hash": "da65c1864debfe11", + "hash": "0212a19069119e72", "files": 23 }, "music-to-video": { - "hash": "562656e2a2f3a193", + "hash": "55a2b5fdcd6f892c", "files": 132 }, "pr-to-video": { - "hash": "0eb5abe09844cee4", + "hash": "44a9877e7ea1289e", "files": 29 }, "product-launch-video": { - "hash": "84b33f077fff496d", + "hash": "8cce4a32487eaf80", "files": 26 }, "remotion-to-hyperframes": { diff --git a/skills/embedded-captions/SKILL.md b/skills/embedded-captions/SKILL.md index 1a8c9fd6f9..0465617a7a 100644 --- a/skills/embedded-captions/SKILL.md +++ b/skills/embedded-captions/SKILL.md @@ -102,7 +102,7 @@ Read the samples. Refuse if: ## Pipeline — 5 steps ``` -1. hyperframes init --non-interactive --video +1. hyperframes init --non-interactive --video --skill=embedded-captions 2. bash scripts/prepare.sh # matte ∥ transcribe (parallel) → safe-zones. One command. # → frames_fg/ transcript.json safe-zones.json 3. [AGENT STEP — the only creative step] author a small JSON; see below by mode diff --git a/skills/embedded-captions/references/bespoke-vs-presets.md b/skills/embedded-captions/references/bespoke-vs-presets.md index d6f8fd4406..8311cb7de9 100644 --- a/skills/embedded-captions/references/bespoke-vs-presets.md +++ b/skills/embedded-captions/references/bespoke-vs-presets.md @@ -135,7 +135,7 @@ For a new video that's clearly similar to an existing canonical example: ```bash # 1. Scaffold the project -hyperframes init --non-interactive --video +hyperframes init --non-interactive --video --skill=embedded-captions # 2. Matte + transcribe node scripts/matte.cjs diff --git a/skills/faceless-explainer/SKILL.md b/skills/faceless-explainer/SKILL.md index d8392265e2..3a519b3f48 100644 --- a/skills/faceless-explainer/SKILL.md +++ b/skills/faceless-explainer/SKILL.md @@ -27,7 +27,7 @@ Goal: Enter with a confirmed brief, create the HyperFrames project, and make the Initialize only if `hyperframes.json` is missing. Name `` from the topic in kebab-case, such as `compound-interest-explained`; never use workspace name or timestamp. -`npx hyperframes init "videos/" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. +`npx hyperframes init "videos/" --non-interactive --example=blank --skill=faceless-explainer` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. After init, let `` be `videos/` and run every subsequent relative-path command with that directory as its working directory. In the commands below, `.` means ``; never write `.media`, `capture`, or output files in the caller directory. diff --git a/skills/general-video/SKILL.md b/skills/general-video/SKILL.md index 4f29467484..0480d2cccb 100644 --- a/skills/general-video/SKILL.md +++ b/skills/general-video/SKILL.md @@ -39,7 +39,7 @@ Apply the first matching row; do not evaluate lower state rows: For a new project, choose a kebab-case directory name from the brief and scaffold before writing the brief: ```bash -npx hyperframes init "videos/" --non-interactive --example=blank +npx hyperframes init "videos/" --non-interactive --example=blank --skill=general-video ``` Then write `BRIEF.md` at the project root using `../hyperframes-core/references/brief-format.md`. In an existing project, the root is the directory containing `hyperframes.json`. Record only the confirmed preference-backed fields named by the brief format, using `node /scripts/prefs.mjs record --hyperframes `; never record inferred defaults. Here `` is the installed `/media-use` skill directory and `` is the directory containing `hyperframes.json`. If the intent layer adopted a recipe, apply it now with `node /scripts/recipe.mjs use --hyperframes --name ` and do not ask again. diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index 938a3b5403..8db28f39ef 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -97,6 +97,8 @@ Use `selection.target.hfId` when available, otherwise its selector and source fi | Self-managed distributed AWS render | `npx hyperframes lambda render --width 1920 --height 1080 --wait` | | Self-managed distributed GCP render | `npx hyperframes cloudrun render --width 1920 --height 1080 --wait` | +Skill attribution is automatic — the examples above need no `--skill`. A project scaffolded by a workflow (`hyperframes init --skill=`) records its owning skill in `hyperframes.json`, and every later render inherits it on anonymous telemetry: re-renders, `npm run render`, and `--batch` alike. Pass `--skill=` explicitly only to stamp a project that was not created through a workflow (its first render then persists it). + Use cloud rendering when the user wants hosted rendering without local Chrome, FFmpeg, or AWS. Use Lambda only when AWS ownership is a requirement. Use Cloud Run only when GCP ownership is a requirement. Read the matching reference before running any cloud path. After verifying a successful render, send one feedback report unless telemetry is disabled or the user opted out: diff --git a/skills/hyperframes-cli/references/init-and-scaffold.md b/skills/hyperframes-cli/references/init-and-scaffold.md index bfd327aa5f..9b2e0a8686 100644 --- a/skills/hyperframes-cli/references/init-and-scaffold.md +++ b/skills/hyperframes-cli/references/init-and-scaffold.md @@ -21,6 +21,7 @@ Templates: `blank`, `warm-grain`, `play-mode`, `swiss-grid`, `vignelli`, `decisi Other useful flags: - `--resolution` — preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k`, `portrait-4k`, `square` (1080×1080), `square-4k`. Aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `4k-square`. +- `--skill=` — record the owning authoring workflow (e.g. `product-launch-video`) in `hyperframes.json`, so every later render of this project — re-renders, `npm run render`, `--batch` — is attributed to it on anonymous telemetry without re-passing the flag. Creation workflows set this automatically; you rarely pass it by hand. - `--skip-skills` — **temporarily ignored**: `init` always checks AI coding skills against GitHub while the skills.sh registry catches up. To opt out (CI/tests), set the `HYPERFRAMES_SKIP_SKILLS=1` env var instead. - `--skip-transcribe` — don't auto-transcribe `--audio` / `--video` with Whisper. - `--model`, `--language` — Whisper model / language for the auto-transcription. diff --git a/skills/motion-graphics/SKILL.md b/skills/motion-graphics/SKILL.md index 088e229a07..90bf727088 100644 --- a/skills/motion-graphics/SKILL.md +++ b/skills/motion-graphics/SKILL.md @@ -84,7 +84,7 @@ Only when `$PROJECT_DIR/hyperframes.json` is absent: ```bash PROJECT_DIR="${MOTION_GRAPHICS_DIR:-videos/}" mkdir -p "$(dirname "$PROJECT_DIR")" -npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank +npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank --skill=motion-graphics ``` `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. diff --git a/skills/music-to-video/SKILL.md b/skills/music-to-video/SKILL.md index bfc0ccf401..7c5402f698 100644 --- a/skills/music-to-video/SKILL.md +++ b/skills/music-to-video/SKILL.md @@ -40,7 +40,7 @@ If no offline provider can satisfy the required music capability, surface the bl Initialize only if `hyperframes.json` is missing. Name `` from the brief in kebab-case, such as `midnight-drive-loop` — never a timestamp. `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. ```bash -npx hyperframes init "videos/" --non-interactive --example=blank +npx hyperframes init "videos/" --non-interactive --example=blank --skill=music-to-video mkdir -p "$PROJECT_DIR/assets" "$PROJECT_DIR/renders" cp "" "$PROJECT_DIR/assets/bgm.mp3" # extract from a video first if needed # only if the user gave you images/videos: diff --git a/skills/pr-to-video/SKILL.md b/skills/pr-to-video/SKILL.md index 7aed35787c..a4999132ca 100644 --- a/skills/pr-to-video/SKILL.md +++ b/skills/pr-to-video/SKILL.md @@ -42,7 +42,7 @@ The capability preflight runs before fetch, story work, audio, or frame dispatch Initialize only if `$PROJECT_DIR/hyperframes.json` is missing. Its basename comes from the PR, such as `acme-sdk-pr-1842`; never use the workspace name or a timestamp. -`npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. +`npx hyperframes init "$PROJECT_DIR" --non-interactive --example=blank --skill=pr-to-video` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. Every relative-path command below runs with `$PROJECT_DIR` as its working directory. Examples without an explicit subshell mean `(cd "$PROJECT_DIR" && …)`; never change the caller repository's working tree. diff --git a/skills/product-launch-video/SKILL.md b/skills/product-launch-video/SKILL.md index 3615d487e7..c79fc37136 100644 --- a/skills/product-launch-video/SKILL.md +++ b/skills/product-launch-video/SKILL.md @@ -29,7 +29,7 @@ Goal: Enter with a confirmed brief, create the HyperFrames project, and make the Initialize only if `hyperframes.json` is missing. Name `` from the brand or domain in kebab-case, such as `acme-promo`; never use workspace name or timestamp. -`npx hyperframes init "videos/" --non-interactive --example=blank` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. +`npx hyperframes init "videos/" --non-interactive --example=blank --skill=product-launch-video` — `init` checks the installed skills against the latest on GitHub and updates the global set if any are out of date. After init, let `` be `videos/` and run every subsequent relative-path command with that directory as its working directory. In the commands below, `.` means ``; never write `.media`, `capture`, or output files in the caller directory. From f10cb5d0dfc57052467aab45d695c0eea4889a2f Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 28 Jul 2026 17:35:00 +0800 Subject: [PATCH 2/4] fix(cli): patch hyperframes.json in place when seeding the authoring skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/cli/src/utils/projectConfig.test.ts | 74 ++++++++++++++++++++ packages/cli/src/utils/projectConfig.ts | 37 +++++++--- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/utils/projectConfig.test.ts b/packages/cli/src/utils/projectConfig.test.ts index 793935513e..f783a95406 100644 --- a/packages/cli/src/utils/projectConfig.test.ts +++ b/packages/cli/src/utils/projectConfig.test.ts @@ -289,5 +289,79 @@ describe("projectConfig", () => { 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 }); + } + }); }); }); diff --git a/packages/cli/src/utils/projectConfig.ts b/packages/cli/src/utils/projectConfig.ts index f94b4b68ea..51dbba012e 100644 --- a/packages/cli/src/utils/projectConfig.ts +++ b/packages/cli/src/utils/projectConfig.ts @@ -7,7 +7,7 @@ * point at custom registries or reshape their project layout. */ -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, 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"; @@ -151,18 +151,39 @@ export function resolveAutoProxy(projectDir: string, flagValue: boolean | undefi * 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); try { - const current = readProjectConfig(projectDir); - if (current?.authoringSkill) return; - writeProjectConfig(projectDir, { - ...(current ?? DEFAULT_PROJECT_CONFIG), - authoringSkill: skill, - }); + if (!existsSync(path)) { + writeProjectConfig(projectDir, { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill }); + return; + } + const text = readFileSync(path, "utf-8"); + const parsed: unknown = JSON.parse(text); + // A non-object config is malformed; leave the user's file untouched rather + // than clobbering it from a render. + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return; + const raw = parsed as Record; + // Seed-once. Normalized so a hand-edited garbage slug neither reaches + // telemetry nor wedges the seed — the next `--skill` render heals it. + if (normalizeSkillSlug(raw.authoringSkill)) return; + raw.authoringSkill = skill; + const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " "; + writeFileSync(path, JSON.stringify(raw, null, indent) + "\n", "utf-8"); } catch { - // Attribution is best-effort telemetry, never a render blocker. + // Corrupt JSON, or a read-only project directory: attribution is + // best-effort telemetry, never a render blocker. } } From af0ce347646cd8271f8bf1b926aaf1d807e82f2f Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 28 Jul 2026 17:59:02 +0800 Subject: [PATCH 3/4] 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. --- packages/cli/src/commands/render.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4d0df774d5..93dda3c0a0 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,7 +1,7 @@ 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"; @@ -562,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). From 356fb94081b94f497e5b4a51ca73b0e2d4b3c56c Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 28 Jul 2026 18:11:14 +0800 Subject: [PATCH 4/4] 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` 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. --- packages/cli/src/utils/projectConfig.ts | 51 ++++++++++++++++++------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/utils/projectConfig.ts b/packages/cli/src/utils/projectConfig.ts index 51dbba012e..c3afc77096 100644 --- a/packages/cli/src/utils/projectConfig.ts +++ b/packages/cli/src/utils/projectConfig.ts @@ -7,7 +7,7 @@ * point at custom registries or reshape their project layout. */ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +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"; @@ -140,6 +140,16 @@ 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 { + 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`, @@ -165,25 +175,38 @@ export function seedProjectAuthoringSkill(projectDir: string, rawSkill: unknown) 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 { - if (!existsSync(path)) { - writeProjectConfig(projectDir, { ...DEFAULT_PROJECT_CONFIG, authoringSkill: skill }); - return; + 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. + } } - const text = readFileSync(path, "utf-8"); + return; + } + + try { const parsed: unknown = JSON.parse(text); - // A non-object config is malformed; leave the user's file untouched rather - // than clobbering it from a render. - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return; - const raw = parsed as Record; + // 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(raw.authoringSkill)) return; - raw.authoringSkill = skill; + if (normalizeSkillSlug(parsed.authoringSkill)) return; + parsed.authoringSkill = skill; const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " "; - writeFileSync(path, JSON.stringify(raw, null, indent) + "\n", "utf-8"); + writeFileSync(path, JSON.stringify(parsed, null, indent) + "\n", "utf-8"); } catch { - // Corrupt JSON, or a read-only project directory: attribution is - // best-effort telemetry, never a render blocker. + // Corrupt JSON, or a read-only file: attribution is best-effort telemetry, + // never a render blocker. } }