feat(cli): add remote template fetching via giget (#162)

* 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
This commit is contained in:
James Russo
2026-03-31 13:41:01 -07:00
committed by GitHub
parent b43b6fb1dc
commit b866a28545
6 changed files with 229 additions and 57 deletions
+39 -23
View File
@@ -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<TemplateOption[]> {
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];
}
+103
View File
@@ -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<RemoteTemplateInfo[]> {
// 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/<templateId>` in the hyperframes repo.
*/
export async function fetchRemoteTemplate(
templateId: string,
destDir: string,
options?: { ref?: string },
): Promise<void> {
// 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.`,
);
}
}