From b866a285452546303c8549aed9bf22edfb10f454 Mon Sep 17 00:00:00 2001 From: James Russo Date: Tue, 31 Mar 2026 16:41:01 -0400 Subject: [PATCH] feat(cli): add remote template fetching via giget (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add remote template fetching via giget * fix: update remote.ts to use templates/ instead of examples/ * fix(cli): validate template ID against manifest before downloading Fails fast with available template list instead of downloading an empty directory for nonexistent templates. * refactor(cli): simplify to single --template flag with dynamic validation - Remove --example flag (--template handles bundled + remote) - Remove static ALL_TEMPLATE_IDS list (validates against GitHub manifest) - No CLI release needed to add new templates — just add to templates/ and templates.json - scaffoldProject auto-detects bundled vs remote * chore: update lockfiles for giget dependency * fix(cli): remove undefined isAudioOnly reference --- bun.lock | 13 +-- packages/cli/package.json | 3 +- packages/cli/src/commands/init.ts | 93 ++++++++++++++------ packages/cli/src/templates/generators.ts | 62 +++++++++----- packages/cli/src/templates/remote.ts | 103 +++++++++++++++++++++++ pnpm-lock.yaml | 12 +++ 6 files changed, 229 insertions(+), 57 deletions(-) create mode 100644 packages/cli/src/templates/remote.ts diff --git a/bun.lock b/bun.lock index 1514bb7da..4f9fcc0ee 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.1.13", + "version": "0.1.15", "bin": { "hyperframes": "./dist/cli.js", }, @@ -30,6 +30,7 @@ "cheerio": "^1.2.0", "citty": "^0.2.1", "esbuild": "^0.25.0", + "giget": "^3.2.0", "hono": "^4.0.0", "mime-types": "^3.0.2", "open": "^10.0.0", @@ -58,7 +59,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.1.13", + "version": "0.1.15", "dependencies": { "@chenglou/pretext": "^0.0.3", }, @@ -84,7 +85,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.1.13", + "version": "0.1.15", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -101,7 +102,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.1.13", + "version": "0.1.15", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -131,7 +132,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.1.13", + "version": "0.1.15", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -955,6 +956,8 @@ "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "5.2.0", "data-uri-to-buffer": "6.0.2", "debug": "4.4.3" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + "giget": ["giget@3.2.0", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A=="], + "git-raw-commits": ["git-raw-commits@5.0.1", "", { "dependencies": { "@conventional-changelog/git-client": "2.6.0", "meow": "13.2.0" }, "bin": { "git-raw-commits": "src/cli.js" } }, "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ=="], "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "3.3.1", "jackspeak": "3.4.3", "minimatch": "9.0.9", "minipass": "7.1.3", "package-json-from-dist": "1.0.1", "path-scurry": "1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 4043b3c7b..0358964cc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,7 +21,7 @@ "build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts", "build:studio": "cd ../studio && bun run build", "build:runtime": "tsx scripts/build-runtime.ts", - "build:copy": "mkdir -p dist/studio dist/docs dist/templates dist/skills && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/* dist/templates/ && cp -r ../../skills/hyperframes-compose ../../skills/hyperframes-captions dist/skills/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)", + "build:copy": "mkdir -p dist/studio dist/docs dist/templates dist/skills && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/blank src/templates/_shared dist/templates/ && cp -r ../../skills/hyperframes-compose ../../skills/hyperframes-captions dist/skills/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -31,6 +31,7 @@ "cheerio": "^1.2.0", "citty": "^0.2.1", "esbuild": "^0.25.0", + "giget": "^3.2.0", "hono": "^4.0.0", "mime-types": "^3.0.2", "open": "^10.0.0", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a55aa6f86..f7a1edbcd 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -14,7 +14,12 @@ import { execFileSync, spawn } from "node:child_process"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { printBanner } from "../ui/banner.js"; -import { TEMPLATES, type TemplateId } from "../templates/generators.js"; +import { + BUNDLED_TEMPLATES, + resolveTemplateList, + type TemplateOption, +} from "../templates/generators.js"; +import { fetchRemoteTemplate } from "../templates/remote.js"; import { trackInitTemplate } from "../telemetry/events.js"; import { hasFFmpeg } from "../whisper/manager.js"; @@ -80,8 +85,6 @@ async function installSkills(interactive: boolean): Promise { } } -const ALL_TEMPLATE_IDS = TEMPLATES.map((t) => t.id); - interface VideoMeta { durationSeconds: number; width: number; @@ -357,17 +360,22 @@ async function handleVideoFile( // scaffoldProject — copy template, patch video refs, write meta.json // --------------------------------------------------------------------------- -function scaffoldProject( +async function scaffoldProject( destDir: string, name: string, - templateId: TemplateId, + templateId: string, localVideoName: string | undefined, durationSeconds?: number, -): void { +): Promise { mkdirSync(destDir, { recursive: true }); + // Use bundled template if available, otherwise fetch from GitHub const templateDir = getStaticTemplateDir(templateId); - cpSync(templateDir, destDir, { recursive: true }); + if (existsSync(templateDir)) { + cpSync(templateDir, destDir, { recursive: true }); + } else { + await fetchRemoteTemplate(templateId, destDir); + } patchVideoSrc(destDir, localVideoName, durationSeconds); writeFileSync( @@ -430,7 +438,7 @@ Examples: name: { type: "positional", description: "Project name", required: false }, template: { type: "string", - description: `Template (${ALL_TEMPLATE_IDS.join(", ")})`, + description: "Template name (e.g. warm-grain, swiss-grid, blank)", alias: "t", }, video: { @@ -481,13 +489,7 @@ Examples: // Non-interactive mode — all inputs from flags, defaults where missing // ----------------------------------------------------------------------- if (!interactive) { - const resolvedTemplate = templateFlag ?? "blank"; - if (!ALL_TEMPLATE_IDS.includes(resolvedTemplate as TemplateId)) { - console.error(c.error(`Unknown template: ${resolvedTemplate}`)); - console.error(`Available: ${ALL_TEMPLATE_IDS.join(", ")}`); - process.exit(1); - } - const templateId = resolvedTemplate as TemplateId; + const templateId = templateFlag ?? "blank"; const name = args.name ?? "my-video"; const destDir = resolve(name); @@ -557,7 +559,23 @@ Examples: } // Scaffold - scaffoldProject(destDir, basename(destDir), templateId, localVideoName, videoDuration); + try { + await scaffoldProject( + destDir, + basename(destDir), + templateId, + localVideoName, + videoDuration, + ); + } catch (err) { + console.error( + c.error( + `Failed to scaffold template "${templateId}": ${err instanceof Error ? err.message : err}`, + ), + ); + console.error(c.dim("Use --template blank for offline use.")); + process.exit(1); + } trackInitTemplate(templateId); const transcriptFile = resolve(destDir, "transcript.json"); if (existsSync(transcriptFile)) { @@ -708,21 +726,22 @@ Examples: } // 3. Pick template — skip prompt if --template was provided - let templateId: TemplateId; - if (templateFlag && ALL_TEMPLATE_IDS.includes(templateFlag as TemplateId)) { - templateId = templateFlag as TemplateId; + let templateId: string; + + if (templateFlag) { + templateId = templateFlag; } else { - if (templateFlag) { - clack.log.warn(`Unknown template "${templateFlag}" — pick from the list below`); - } + // Resolve full template list (bundled + remote) + const allTemplates = await resolveTemplateList(); + const defaultTemplate = "blank"; const templateResult = await clack.select({ message: "Pick a template", - options: TEMPLATES.map((t) => ({ + options: allTemplates.map((t: TemplateOption) => ({ value: t.id, label: t.label, - hint: t.hint, + hint: t.source === "remote" ? `${t.hint} (download)` : t.hint, })), - initialValue: "blank" as TemplateId, + initialValue: defaultTemplate, }); if (clack.isCancel(templateResult)) { clack.cancel("Setup cancelled."); @@ -731,14 +750,32 @@ Examples: templateId = templateResult; } - // 4. Copy template and patch - scaffoldProject(destDir, name, templateId, localVideoName, videoDuration); + // 4. Scaffold project (bundled templates are instant, remote templates download from GitHub) + const spin = clack.spinner(); + const isBundled = BUNDLED_TEMPLATES.some((t) => t.id === templateId); + if (!isBundled) { + spin.start(`Downloading template ${c.accent(templateId)}...`); + } + try { + await scaffoldProject(destDir, name, templateId, localVideoName, videoDuration); + if (!isBundled) { + spin.stop(c.success(`Downloaded ${templateId}`)); + } + } catch (err) { + if (!isBundled) { + spin.stop(c.error("Download failed")); + } + clack.log.error( + `${err instanceof Error ? err.message : err}\n${c.dim("Use --template blank for offline use.")}`, + ); + process.exit(1); + } trackInitTemplate(templateId); // 4b. Patch captions with transcript if available const transcriptFile = resolve(destDir, "transcript.json"); if (existsSync(transcriptFile)) { - patchTranscript(destDir, transcriptFile); + await patchTranscript(destDir, transcriptFile); } // 5. Install AI coding skills diff --git a/packages/cli/src/templates/generators.ts b/packages/cli/src/templates/generators.ts index 87e369e76..c7629e5e6 100644 --- a/packages/cli/src/templates/generators.ts +++ b/packages/cli/src/templates/generators.ts @@ -1,32 +1,48 @@ -export type TemplateId = - | "blank" - | "warm-grain" - | "play-mode" - | "swiss-grid" - | "vignelli" - | "decision-tree" - | "kinetic-type" - | "product-promo" - | "nyt-graph"; +import { listRemoteTemplates, type RemoteTemplateInfo } from "./remote.js"; + +export type TemplateSource = "bundled" | "remote"; export interface TemplateOption { - id: TemplateId; + id: string; label: string; hint: string; + source: TemplateSource; } -export const TEMPLATES: TemplateOption[] = [ - { id: "blank", label: "Blank", hint: "Empty composition — just the scaffolding" }, - { id: "warm-grain", label: "Warm Grain", hint: "Cream aesthetic with grain texture" }, - { id: "play-mode", label: "Play Mode", hint: "Playful elastic animations" }, - { id: "swiss-grid", label: "Swiss Grid", hint: "Structured grid layout" }, - { id: "vignelli", label: "Vignelli", hint: "Bold typography with red accents" }, - { id: "decision-tree", label: "Decision Tree", hint: "Animated flowchart with branching paths" }, - { id: "kinetic-type", label: "Kinetic Type", hint: "Bold kinetic typography promo" }, +/** Templates bundled in the CLI package (available offline). */ +export const BUNDLED_TEMPLATES: TemplateOption[] = [ { - id: "product-promo", - label: "Product Promo", - hint: "Multi-scene product showcase with SVG assets", + id: "blank", + label: "Blank", + hint: "Empty composition — just the scaffolding", + source: "bundled", }, - { id: "nyt-graph", label: "NYT Graph", hint: "Animated data chart in print editorial style" }, ]; + +/** + * Resolve the full template list by merging bundled and remote templates. + * Fetches templates.json from GitHub (cached 24h). No CLI release needed to add templates. + * If offline, returns only bundled templates. + */ +export async function resolveTemplateList(): Promise { + const bundled = [...BUNDLED_TEMPLATES]; + const bundledIds = new Set(bundled.map((t) => t.id)); + + let remote: RemoteTemplateInfo[] = []; + try { + remote = await listRemoteTemplates(); + } catch { + // Offline — return bundled only + } + + const remoteOptions: TemplateOption[] = remote + .filter((r) => !r.bundled && !bundledIds.has(r.id)) + .map((r) => ({ + id: r.id, + label: r.label, + hint: r.hint, + source: "remote" as const, + })); + + return [...bundled, ...remoteOptions]; +} diff --git a/packages/cli/src/templates/remote.ts b/packages/cli/src/templates/remote.ts new file mode 100644 index 000000000..67ecf8d77 --- /dev/null +++ b/packages/cli/src/templates/remote.ts @@ -0,0 +1,103 @@ +/** + * Remote Template Fetching + * + * Downloads templates from the hyperframes GitHub repository using giget. + * Templates live in the `templates/` directory of the repo. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +const REPO = "heygen-com/hyperframes"; +const TEMPLATES_DIR = "templates"; +const MANIFEST_FILENAME = "templates.json"; + +/** Cache directory for remote template metadata. */ +const CACHE_DIR = join(homedir(), ".hyperframes", "cache"); +const MANIFEST_CACHE_PATH = join(CACHE_DIR, "remote-templates.json"); +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +export interface RemoteTemplateInfo { + id: string; + label: string; + hint: string; + bundled: boolean; +} + +interface ManifestCache { + fetchedAt: number; + templates: RemoteTemplateInfo[]; +} + +/** + * Fetch the remote template manifest from GitHub. + * Caches the result for 24 hours to avoid rate limits. + */ +export async function listRemoteTemplates(): Promise { + // Check cache first + if (existsSync(MANIFEST_CACHE_PATH)) { + try { + const cached: ManifestCache = JSON.parse(readFileSync(MANIFEST_CACHE_PATH, "utf-8")); + if (Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.templates; + } + } catch { + // Cache corrupt — refetch + } + } + + // Fetch from GitHub raw content + const url = `https://raw.githubusercontent.com/${REPO}/main/${TEMPLATES_DIR}/${MANIFEST_FILENAME}`; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const data = (await res.json()) as { templates: RemoteTemplateInfo[] }; + const templates = data.templates; + + // Write cache + mkdirSync(CACHE_DIR, { recursive: true }); + const cache: ManifestCache = { fetchedAt: Date.now(), templates }; + writeFileSync(MANIFEST_CACHE_PATH, JSON.stringify(cache), "utf-8"); + + return templates; + } catch { + // Offline or rate-limited — return empty (caller should fall back to bundled) + return []; + } +} + +/** + * Download a template from GitHub into destDir using giget. + * Fetches from `examples/` in the hyperframes repo. + */ +export async function fetchRemoteTemplate( + templateId: string, + destDir: string, + options?: { ref?: string }, +): Promise { + // Validate against manifest before downloading + const known = await listRemoteTemplates(); + if (known.length > 0 && !known.some((t) => t.id === templateId)) { + const available = known.map((t) => t.id).join(", "); + throw new Error(`Template "${templateId}" not found. Available: ${available}`); + } + + const { downloadTemplate } = await import("giget"); + const ref = options?.ref ?? "main"; + const source = `github:${REPO}/${TEMPLATES_DIR}/${templateId}#${ref}`; + + await downloadTemplate(source, { + dir: destDir, + force: true, + }); + + // Safety check — giget can succeed with empty dir if path doesn't exist + if (!existsSync(join(destDir, "index.html"))) { + throw new Error( + `Template "${templateId}" downloaded but missing index.html. The template may be malformed.`, + ); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce9667a3..45baf7034 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: esbuild: specifier: ^0.25.0 version: 0.25.12 + giget: + specifier: ^3.2.0 + version: 3.2.0 hono: specifier: ^4.0.0 version: 4.12.9 @@ -117,6 +120,9 @@ importers: '@chenglou/pretext': specifier: ^0.0.3 version: 0.0.3 + hono: + specifier: ^4.0.0 + version: 4.12.9 devDependencies: '@types/jsdom': specifier: ^28.0.0 @@ -2448,6 +2454,10 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} + giget@3.2.0: + resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} + hasBin: true + git-raw-commits@5.0.1: resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} engines: {node: '>=18'} @@ -5450,6 +5460,8 @@ snapshots: transitivePeerDependencies: - supports-color + giget@3.2.0: {} + git-raw-commits@5.0.1(conventional-commits-parser@6.3.0): dependencies: '@conventional-changelog/git-client': 2.6.0(conventional-commits-parser@6.3.0)