feat: scaffold package scripts on init (#576)

## Problem

New HyperFrames projects should feel like normal JavaScript projects immediately after `hyperframes init`: users should have a canonical `npm run dev`, `npm run check`, `npm run render`, and `npm run publish` loop without needing to memorize raw CLI commands.

At the same time, the scaffold should stay opinionated. Adding many aliases would make the project surface harder to explain and maintain.

## What this fixes

- Writes a default `package.json` during `hyperframes init` when the selected example does not already provide one.
- Adds four project scripts only:
  - `dev` -> preview in Studio
  - `check` -> lint, validate, and inspect in sequence
  - `render` -> render the video
  - `publish` -> publish the project
- Pins generated scripts to the CLI version that created the project in packaged builds, while keeping source-checkout tests on the unpinned dev fallback.
- Uses `npx --yes` inside scripts so first-run commands do not stop on an install confirmation prompt.
- Updates generated `AGENTS.md` and `CLAUDE.md` guidance to present the same four-command workflow.
- Updates the non-interactive init success message to include `npm run dev`, `npm run check`, and `npm run render`.

## Root cause

`scaffoldProject()` copied the example, wrote `meta.json` and `hyperframes.json`, then copied agent guidance files. It never created a package manifest, so generated projects had no project-local command contract even though the workflow has stable repeated commands.

This revision keeps the scaffold narrow: `package.json` is the project workflow contract, but direct CLI usage remains available for advanced or one-off commands.

## Verification

### Local checks

- TDD red check from the first pass: `bun run --filter @hyperframes/cli test src/commands/init.test.ts` failed after updating the expected generated UX because `npm run check` was not emitted yet.
- `bun run --filter @hyperframes/cli test src/commands/init.test.ts`
- `bunx oxlint packages/cli/src/commands/init.ts packages/cli/src/commands/init.test.ts`
- `bunx oxfmt --check packages/cli/src/commands/init.ts packages/cli/src/commands/init.test.ts packages/cli/src/templates/_shared/AGENTS.md packages/cli/src/templates/_shared/CLAUDE.md`
- `bun run --filter @hyperframes/cli typecheck`
- `bun run --filter @hyperframes/cli test`
- `bun run --filter @hyperframes/cli build`
- `bun run --filter @hyperframes/studio build`
- `git diff --check`
- `node packages/cli/dist/cli.js --version`

Generated-project smoke at `/tmp/hf-init-package-scripts-opinionated`:

- `node packages/cli/dist/cli.js init /tmp/hf-init-package-scripts-opinionated --example blank --non-interactive --skip-skills`
- inspected generated `package.json` and confirmed exactly `dev`, `check`, `render`, and `publish`
- confirmed packaged scripts use `npx --yes hyperframes@0.4.39 ...`
- `npm run check`
- `npm run render -- --quality draft --workers 1 --fps 24 --output /tmp/hf-init-package-scripts-opinionated.mp4`
- `ffprobe -v error -select_streams v:0 -show_entries stream=width,height,avg_frame_rate,duration -show_entries format=duration,size -of json /tmp/hf-init-package-scripts-opinionated.mp4`
- `npm run publish -- --help`

### Browser verification

- Started the generated project through the new script: `npm run dev -- --port 5199`.
- Used `agent-browser` to open `http://localhost:5199/#project/hf-init-package-scripts-opinionated`.
- Verified the Studio project loaded with the expected project name, controls, timeline, and composition player frame.
- Captured screenshot: `/tmp/hf-init-package-scripts-opinionated-browser.png`.
- Captured agent-browser-driven recording: `/tmp/hf-init-package-scripts-opinionated-browser.webm`.
- Verified recording metadata with `ffprobe`: 14.4s, 61 KB.

## Notes

- The generated source-checkout test still expects unpinned `npx --yes hyperframes ...` because source mode reports `0.0.0-dev`; the packaged CLI smoke covers the real-user pinned path.
- `npm run publish` was verified with `--help` only to avoid creating a real publish side effect during PR validation.
This commit is contained in:
Miguel Ángel
2026-04-30 19:21:16 +02:00
committed by GitHub
parent 4fa2633e82
commit 61bb814a7f
4 changed files with 88 additions and 17 deletions
+21 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -24,13 +24,32 @@ function runInit(args: string[]): { status: number; stdout: string; stderr: stri
} }
describe("hyperframes init flag rename", () => { describe("hyperframes init flag rename", () => {
it("--example blank scaffolds a bundled project", () => { it("--example blank scaffolds a bundled project with npm scripts", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); const dir = mkdtempSync(join(tmpdir(), "hf-init-test-"));
const target = join(dir, "proj"); const target = join(dir, "proj");
try { try {
const res = runInit([target, "--example", "blank", "--non-interactive", "--skip-skills"]); const res = runInit([target, "--example", "blank", "--non-interactive", "--skip-skills"]);
expect(res.status).toBe(0); expect(res.status).toBe(0);
expect(existsSync(join(target, "index.html"))).toBe(true); expect(existsSync(join(target, "index.html"))).toBe(true);
expect(res.stdout).toContain("npm run dev");
expect(res.stdout).toContain("npm run check");
expect(res.stdout).toContain("npm run render");
const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf-8")) as {
private?: boolean;
type?: string;
scripts?: Record<string, string>;
};
expect(pkg.private).toBe(true);
expect(pkg.type).toBe("module");
expect(pkg.scripts).toMatchObject({
dev: "npx --yes hyperframes preview",
check:
"npx --yes hyperframes lint && npx --yes hyperframes validate && npx --yes hyperframes inspect",
render: "npx --yes hyperframes render",
publish: "npx --yes hyperframes publish",
});
expect(Object.keys(pkg.scripts ?? {}).sort()).toEqual(["check", "dev", "publish", "render"]);
} finally { } finally {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
} }
+54 -3
View File
@@ -32,6 +32,7 @@ import {
import { fetchRemoteTemplate } from "../templates/remote.js"; import { fetchRemoteTemplate } from "../templates/remote.js";
import { trackInitTemplate } from "../telemetry/events.js"; import { trackInitTemplate } from "../telemetry/events.js";
import { hasFFmpeg } from "../whisper/manager.js"; import { hasFFmpeg } from "../whisper/manager.js";
import { VERSION } from "../version.js";
interface VideoMeta { interface VideoMeta {
durationSeconds: number; durationSeconds: number;
@@ -168,6 +169,51 @@ function getSharedTemplateDir(): string {
return resolveAssetDir(["..", "templates", "_shared"], ["templates", "_shared"]); return resolveAssetDir(["..", "templates", "_shared"], ["templates", "_shared"]);
} }
function toPackageName(projectName: string): string {
const normalized = basename(projectName)
.trim()
.toLowerCase()
.replace(/^[._]+/, "")
.replace(/[^a-z0-9._~-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-.]+|[-.]+$/g, "");
return normalized || "hyperframes-project";
}
function getHyperframesPackageSpecifier(): string {
return VERSION === "0.0.0-dev" ? "hyperframes" : `hyperframes@${VERSION}`;
}
function hyperframesScript(command: string): string {
return `npx --yes ${getHyperframesPackageSpecifier()} ${command}`;
}
function writeDefaultPackageJson(destDir: string, projectName: string): void {
const packageJsonPath = resolve(destDir, "package.json");
if (existsSync(packageJsonPath)) return;
writeFileSync(
packageJsonPath,
`${JSON.stringify(
{
name: toPackageName(projectName),
private: true,
type: "module",
scripts: {
dev: hyperframesScript("preview"),
check: `${hyperframesScript("lint")} && ${hyperframesScript("validate")} && ${hyperframesScript("inspect")}`,
render: hyperframesScript("render"),
publish: hyperframesScript("publish"),
},
},
null,
2,
)}\n`,
"utf-8",
);
}
function patchVideoSrc( function patchVideoSrc(
dir: string, dir: string,
videoFilename: string | undefined, videoFilename: string | undefined,
@@ -346,6 +392,8 @@ async function scaffoldProject(
writeProjectConfig(destDir, DEFAULT_PROJECT_CONFIG); writeProjectConfig(destDir, DEFAULT_PROJECT_CONFIG);
} }
writeDefaultPackageJson(destDir, name);
// Copy shared files (CLAUDE.md, AGENTS.md) for AI agent context // Copy shared files (CLAUDE.md, AGENTS.md) for AI agent context
const sharedDir = getSharedTemplateDir(); const sharedDir = getSharedTemplateDir();
if (existsSync(sharedDir)) { if (existsSync(sharedDir)) {
@@ -559,10 +607,13 @@ export default defineCommand({
console.log(` ${c.dim("More patterns: hyperframes.heygen.com/guides/prompting")}`); console.log(` ${c.dim("More patterns: hyperframes.heygen.com/guides/prompting")}`);
console.log(); console.log();
console.log(` ${c.accent("4.")} Preview in the browser:`); console.log(` ${c.accent("4.")} Preview in the browser:`);
console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npx hyperframes preview")}`); console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npm run dev")}`);
console.log(); console.log();
console.log(` ${c.accent("5.")} Render to MP4 when ready:`); console.log(` ${c.accent("5.")} Check the composition:`);
console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npx hyperframes render")}`); console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npm run check")}`);
console.log();
console.log(` ${c.accent("6.")} Render to MP4 when ready:`);
console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npm run render")}`);
console.log(); console.log();
console.log(` ${c.dim("Full docs: hyperframes.heygen.com")}`); console.log(` ${c.dim("Full docs: hyperframes.heygen.com")}`);
return; return;
+6 -6
View File
@@ -13,10 +13,10 @@ Skills encode patterns like `window.__timelines` registration, `data-*` attribut
## Commands ## Commands
```bash ```bash
npx hyperframes preview # preview in browser (studio editor) npm run dev # preview in browser (studio editor)
npx hyperframes render # render to MP4 npm run check # lint + validate + inspect
npx hyperframes lint # validate compositions (errors + warnings) npm run render # render to MP4
npx hyperframes lint --json # machine-readable output for CI npm run publish # publish and get a shareable link
npx hyperframes docs <topic> # reference docs in terminal npx hyperframes docs <topic> # reference docs in terminal
``` ```
@@ -30,10 +30,10 @@ npx hyperframes docs <topic> # reference docs in terminal
## Linting — Always Run After Changes ## Linting — Always Run After Changes
After creating or editing any `.html` composition, run the linter before considering the task complete: After creating or editing any `.html` composition, run the full check before considering the task complete:
```bash ```bash
npx hyperframes lint npm run check
``` ```
Fix all errors before presenting the result. Fix all errors before presenting the result.
+7 -6
View File
@@ -23,9 +23,10 @@
## Commands ## Commands
```bash ```bash
npx hyperframes preview # preview in browser (studio editor) npm run dev # preview in browser (studio editor)
npx hyperframes render # render to MP4 npm run check # lint + validate + inspect
npx hyperframes lint # validate compositions (errors + warnings) npm run render # render to MP4
npm run publish # publish and get a shareable link
npx hyperframes lint --verbose # include info-level findings npx hyperframes lint --verbose # include info-level findings
npx hyperframes lint --json # machine-readable output for CI npx hyperframes lint --json # machine-readable output for CI
npx hyperframes docs <topic> # reference docs in terminal npx hyperframes docs <topic> # reference docs in terminal
@@ -56,13 +57,13 @@ https://hyperframes.heygen.com/llms.txt
## Linting — ALWAYS RUN AFTER CHANGES ## Linting — ALWAYS RUN AFTER CHANGES
After creating or editing any `.html` composition, **always** run the linter before considering the task complete: After creating or editing any `.html` composition, **always** run the full check before considering the task complete:
```bash ```bash
npx hyperframes lint npm run check
``` ```
Fix all errors before presenting the result. Warnings are informational and usually safe to ignore. Fix all errors before presenting the result. Inspect warnings should be reviewed before rendering.
## Key Rules ## Key Rules