diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e19b5ce0ce..e5ac96dbef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -282,6 +282,10 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 + - name: Install FFmpeg for real CLI render integration + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends ffmpeg - uses: ./.github/actions/prepare-ffmpeg-bin - run: bash scripts/ci/install-workspace-dependencies.sh - run: bun run test:scripts diff --git a/docs/schema/registry-item.json b/docs/schema/registry-item.json index a2051a5bf2..b34f3d2d93 100644 --- a/docs/schema/registry-item.json +++ b/docs/schema/registry-item.json @@ -128,6 +128,18 @@ "relatedSkill": { "type": "string", "minLength": 1 + }, + "provenance": { + "type": "object", + "required": ["kind", "artifactId", "versionId", "canonicalUri", "sourceDigest"], + "additionalProperties": false, + "properties": { + "kind": { "const": "heygenverse-export" }, + "artifactId": { "type": "string", "format": "uuid" }, + "versionId": { "type": "string", "format": "uuid" }, + "canonicalUri": { "type": "string", "pattern": "^heygenverse://app/" }, + "sourceDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + } } }, "allOf": [ diff --git a/packages/cli/src/auth/index.ts b/packages/cli/src/auth/index.ts index 9d9562b1cc..de8048f24a 100644 --- a/packages/cli/src/auth/index.ts +++ b/packages/cli/src/auth/index.ts @@ -25,7 +25,7 @@ export { export { configDir, credentialPath } from "./paths.js"; -export { tryResolveCredential } from "./resolver.js"; +export { tryResolveCredential, tryResolveOAuthCredential } from "./resolver.js"; export type { ResolvedCredential } from "./resolver.js"; export { AuthClient } from "./client.js"; diff --git a/packages/cli/src/auth/resolver.ts b/packages/cli/src/auth/resolver.ts index 6b0ca0f118..e0eba1f703 100644 --- a/packages/cli/src/auth/resolver.ts +++ b/packages/cli/src/auth/resolver.ts @@ -94,6 +94,17 @@ export async function tryResolveCredential( } } +/** Resolve only a HeyGen OAuth session, ignoring generic API-key sources. */ +export async function tryResolveOAuthCredential( + opts: ResolveOptions = {}, +): Promise { + const now = (opts.now ?? (() => new Date()))(); + const { credentials, source } = await readStore(); + if (source === "absent" || !credentials.oauth) return null; + const fileSource: CredentialSource = source === "file_legacy" ? "file_legacy" : "file_json"; + return pickOAuth(credentials.oauth, now, fileSource); +} + function pickOAuth( tokens: NonNullable>["credentials"]["oauth"]>, now: Date, diff --git a/packages/cli/src/commands/add.oauth.test.ts b/packages/cli/src/commands/add.oauth.test.ts new file mode 100644 index 0000000000..2f14911248 --- /dev/null +++ b/packages/cli/src/commands/add.oauth.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { RegistryItem } from "@hyperframes/core"; + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + install: vi.fn(), + resolve: vi.fn(), +})); + +vi.mock("../registry/threadMessageStackAuthorization.js", () => ({ + authorizeThreadMessageStackInstall: mocks.authorize, +})); +vi.mock("../registry/installer.js", () => ({ installItem: mocks.install })); +vi.mock("../registry/resolver.js", () => ({ + resolveItemWithDependencies: mocks.resolve, + resolveItemsByTag: vi.fn(async () => []), +})); + +import { runAdd } from "./add.js"; + +const item: RegistryItem = { + name: "thread-message-stack", + type: "hyperframes:block", + title: "Thread Message Stack", + description: "Conversation", + dimensions: { width: 1920, height: 1080 }, + duration: 8, + files: [ + { + path: "thread-message-stack.html", + target: "compositions/thread-message-stack.html", + type: "hyperframes:composition", + }, + ], +}; + +describe("direct add thread-message-stack OAuth boundary", () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), "hf-add-oauth-")); + mocks.resolve.mockReset().mockResolvedValue([item]); + mocks.install.mockReset().mockResolvedValue({ written: [join(projectDir, "stack.html")] }); + mocks.authorize.mockReset(); + }); + + afterEach(() => rmSync(projectDir, { recursive: true, force: true })); + + it.each(["api-key-only", "cancelled", "failed"] as const)( + "does not download or materialize when verified HeyGen OAuth is %s", + async (outcome) => { + mocks.authorize.mockResolvedValue(outcome); + + await expect( + runAdd({ name: "thread-message-stack", projectDir, skipClipboard: true }), + ).rejects.toMatchObject({ code: "oauth-required" }); + expect(mocks.authorize).toHaveBeenCalledTimes(1); + expect(mocks.resolve).not.toHaveBeenCalled(); + expect(mocks.install).not.toHaveBeenCalled(); + }, + ); + + it("downloads and materializes exactly once after verified HeyGen OAuth succeeds", async () => { + mocks.authorize.mockResolvedValue("authorized"); + + await expect( + runAdd({ name: "thread-message-stack", projectDir, skipClipboard: true }), + ).resolves.toMatchObject({ ok: true, name: "thread-message-stack" }); + expect(mocks.authorize).toHaveBeenCalledTimes(1); + expect(mocks.install).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 34fae74372..e48efa4381 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -11,11 +11,16 @@ export const examples: Example[] = [ ["Skip the clipboard copy (CI/headless)", "hyperframes add shader-wipe --no-clipboard"], ]; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { resolve, relative } from "node:path"; import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core"; import { c } from "../ui/colors.js"; -import { installItem, resolveItemsByTag } from "../registry/index.js"; +import { installItem } from "../registry/installer.js"; +import { resolveItemsByTag } from "../registry/resolver.js"; +import { + validateThreadMessageStackData, + type ThreadMessageStackData, +} from "../registry/threadMessageStack.js"; import { resolveItemWithDependencies } from "../registry/resolver.js"; import { gateRegistryItemsCompatibility, @@ -28,6 +33,7 @@ import { writeProjectConfig, } from "../utils/projectConfig.js"; import { copyToClipboard } from "../utils/clipboard.js"; +import { authorizeThreadMessageStackInstall } from "../registry/threadMessageStackAuthorization.js"; // ── Target-path resolution ────────────────────────────────────────────────── // `registry-item.json` files specify `target` paths relative to the project @@ -83,6 +89,8 @@ export interface RunAddArgs { skipClipboard?: boolean; /** Current CLI version used for registry metadata compatibility checks. */ cliVersion?: string; + /** Caller-owned messages materialized only for thread-message-stack. */ + threadMessageStackData?: ThreadMessageStackData; } export interface RunAddResult { @@ -106,7 +114,8 @@ export class AddError extends Error { | "wrong-type" | "install-failed" | "example-type" - | "incompatible-cli", + | "incompatible-cli" + | "oauth-required", ) { super(message); this.name = "AddError"; @@ -134,11 +143,19 @@ async function installAll( installPlan: RegistryItem[], destDir: string, baseUrl: string | undefined, + requestedName: string, + threadMessageStackData?: ThreadMessageStackData, ): Promise { const written: string[] = []; try { for (const planItem of installPlan) { - const result = await installItem(planItem, { destDir, baseUrl }); + const result = await installItem(planItem, { + destDir, + baseUrl, + ...(planItem.name === requestedName && threadMessageStackData + ? { threadMessageStackData } + : {}), + }); written.push(...result.written); } } catch (err) { @@ -161,6 +178,19 @@ export async function runAdd(opts: RunAddArgs): Promise { config = DEFAULT_PROJECT_CONFIG; } + // This source-owned primitive is not downloadable through generic registry + // credentials. Gate its named command boundary before registry resolution so + // an API key, cancellation, or failed OAuth cannot fetch even its manifest. + if (opts.name === "thread-message-stack") { + const authorization = await authorizeThreadMessageStackInstall(); + if (authorization !== "authorized") { + throw new AddError( + `thread-message-stack requires verified HeyGen OAuth (${authorization}); no source was downloaded or materialized.`, + "oauth-required", + ); + } + } + // 2. Resolve the requested item and its transitive registryDependencies. // The list comes back topologically sorted: dependencies first, the // requested item last. @@ -195,7 +225,13 @@ export async function runAdd(opts: RunAddArgs): Promise { })); // 5. Install — dependencies first, requested item last. - const written = await installAll(installPlan, projectDir, config.registry); + const written = await installAll( + installPlan, + projectDir, + config.registry, + item.name, + opts.threadMessageStackData, + ); // 6. Build include snippet + clipboard copy for the requested item. const itemForInstall = installPlan[installPlan.length - 1]!; @@ -253,7 +289,14 @@ export default defineCommand({ type: "boolean", description: "Print a machine-readable summary (written files + snippet) to stdout", }, + "messages-file": { + type: "string", + description: + "JSON file with {messages, stagger?, hold?}; valid only for thread-message-stack", + }, }, + // Existing command UX handles item, tag, JSON, clipboard, and file-input modes in one boundary. + // fallow-ignore-next-line complexity async run({ args }) { const projectDir = resolve(args.dir ?? process.cwd()); const json = args.json === true; @@ -262,7 +305,23 @@ export default defineCommand({ // Try single item first. If it fails, check if the name matches a tag. try { - const result = await runAdd({ name: args.name, projectDir, skipClipboard }); + let threadMessageStackData: ThreadMessageStackData | undefined; + if (args["messages-file"]) { + if (args.name !== "thread-message-stack") { + throw new AddError( + "--messages-file is valid only for thread-message-stack.", + "wrong-type", + ); + } + const raw = readFileSync(resolve(projectDir, args["messages-file"]), "utf8"); + threadMessageStackData = validateThreadMessageStackData(JSON.parse(raw)); + } + const result = await runAdd({ + name: args.name, + projectDir, + skipClipboard, + ...(threadMessageStackData ? { threadMessageStackData } : {}), + }); const wroteConfig = !hasConfigBefore && existsSync(projectConfigPath(projectDir)); if (json) { diff --git a/packages/cli/src/commands/catalog-resume.test.ts b/packages/cli/src/commands/catalog-resume.test.ts new file mode 100644 index 0000000000..83b2e6fcff --- /dev/null +++ b/packages/cli/src/commands/catalog-resume.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { spawn } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + createFilePrimitiveInstallStateStore, + createPrimitiveInstallIntent, + filterPrimitiveCatalogItems, + resumePrimitiveInstallExactlyOnce, + type PendingPrimitiveInstall, + type PrimitiveInstallStateStore, +} from "./catalog-resume.js"; + +function memoryStore(): PrimitiveInstallStateStore { + let state: PendingPrimitiveInstall | null = null; + return { + load: async () => state, + save: async (next) => { + state = next; + }, + }; +} + +// Anonymous discovery and resume invariants share one coordinator fixture. +// fallow-ignore-next-line unit-size +describe("catalog OAuth resume", () => { + it("supports anonymous content-free discovery before selection", () => { + const items = [ + { name: "thread-message-stack", title: "Thread Message Stack", description: "Conversation" }, + { name: "shader-wipe", title: "Shader Wipe", description: "Transition" }, + ]; + expect(filterPrimitiveCatalogItems(items, " conversation ").map((item) => item.name)).toEqual([ + "thread-message-stack", + ]); + expect(filterPrimitiveCatalogItems(items, "")).toEqual(items); + }); + + it("preserves a non-content search fingerprint and resumes the same selection exactly once", async () => { + const store = memoryStore(); + const authenticate = vi.fn(async () => true); + const install = vi.fn(async () => undefined); + let authenticated = false; + authenticate.mockImplementation(async () => { + authenticated = true; + return true; + }); + const intent = createPrimitiveInstallIntent({ + itemName: "thread-message-stack", + query: "private authored search text", + artifactId: "21c28523-7487-43e1-927d-43a7fe855859", + versionId: "1aa00c22-b508-4b81-a3a1-4702453d48c2", + funnelId: "funnel-1", + installId: "install-1", + }); + + expect(JSON.stringify(intent)).not.toContain("private authored search text"); + const deps = { + store, + isAuthenticated: async () => authenticated, + authenticate, + install, + }; + await expect(resumePrimitiveInstallExactlyOnce(intent, deps)).resolves.toBe("installed"); + await expect(resumePrimitiveInstallExactlyOnce(intent, deps)).resolves.toBe( + "already-installed", + ); + expect(authenticate).toHaveBeenCalledTimes(1); + expect(install).toHaveBeenCalledTimes(1); + expect(install).toHaveBeenCalledWith(expect.objectContaining({ itemName: intent.itemName })); + }); + + it.each(["cancelled", "failed"] as const)( + "installs nothing when OAuth is %s", + async (outcome) => { + const install = vi.fn(async () => undefined); + const intent = createPrimitiveInstallIntent({ + itemName: "thread-message-stack", + query: "thread", + artifactId: "21c28523-7487-43e1-927d-43a7fe855859", + versionId: "1aa00c22-b508-4b81-a3a1-4702453d48c2", + funnelId: `funnel-${outcome}`, + installId: `install-${outcome}`, + }); + + await expect( + resumePrimitiveInstallExactlyOnce(intent, { + store: memoryStore(), + isAuthenticated: async () => false, + authenticate: async () => outcome, + install, + }), + ).resolves.toBe(outcome); + expect(install).not.toHaveBeenCalled(); + }, + ); + + it("atomically admits only one live installer across independent file-store owners", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-catalog-claim-")); + const statePath = join(dir, "pending.json"); + const intent = createPrimitiveInstallIntent({ + itemName: "thread-message-stack", + query: "thread", + artifactId: "21c28523-7487-43e1-927d-43a7fe855859", + versionId: "1aa00c22-b508-4b81-a3a1-4702453d48c2", + installId: "concurrent-install", + }); + let releaseFirst!: () => void; + const blocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + const install = vi.fn(async () => await blocked); + const deps = (store: PrimitiveInstallStateStore) => ({ + store, + isAuthenticated: async () => true, + authenticate: async () => true, + install, + }); + + try { + const first = resumePrimitiveInstallExactlyOnce( + intent, + deps(createFilePrimitiveInstallStateStore(statePath)), + ); + await vi.waitFor(() => expect(install).toHaveBeenCalledTimes(1)); + const second = resumePrimitiveInstallExactlyOnce( + intent, + deps(createFilePrimitiveInstallStateStore(statePath)), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(install).toHaveBeenCalledTimes(1); + releaseFirst(); + await expect(Promise.all([first, second])).resolves.toEqual([ + "installed", + "already-installed", + ]); + } finally { + releaseFirst(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("recovers a bounded stale claim left by a dead owner", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-catalog-recovery-")); + const statePath = join(dir, "pending.json"); + const claimDir = `${statePath}.claim`; + const intent = createPrimitiveInstallIntent({ + itemName: "thread-message-stack", + query: "thread", + artifactId: "21c28523-7487-43e1-927d-43a7fe855859", + versionId: "1aa00c22-b508-4b81-a3a1-4702453d48c2", + installId: "recovered-install", + }); + const install = vi.fn(async () => undefined); + + try { + mkdirSync(claimDir, { recursive: true }); + writeFileSync( + join(claimDir, "owner.json"), + JSON.stringify({ pid: 2_147_483_647, token: "dead-owner" }), + ); + const stale = new Date(Date.now() - 60_000); + utimesSync(claimDir, stale, stale); + + await expect( + resumePrimitiveInstallExactlyOnce(intent, { + store: createFilePrimitiveInstallStateStore(statePath, { staleClaimMs: 10 }), + isAuthenticated: async () => true, + authenticate: async () => true, + install, + }), + ).resolves.toBe("installed"); + expect(install).toHaveBeenCalledTimes(1); + expect(existsSync(claimDir)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("admits exactly one installer when two Bun processes race the same install id", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-catalog-cross-process-")); + const statePath = join(dir, "pending.json"); + const installLog = join(dir, "installs.log"); + const moduleUrl = pathToFileURL(join(import.meta.dirname, "catalog-resume.ts")).href; + const childSource = ` + import { appendFileSync } from "node:fs"; + import { createFilePrimitiveInstallStateStore, createPrimitiveInstallIntent, resumePrimitiveInstallExactlyOnce } from ${JSON.stringify(moduleUrl)}; + const intent = createPrimitiveInstallIntent({ itemName: "thread-message-stack", query: "thread", artifactId: "21c28523-7487-43e1-927d-43a7fe855859", versionId: "1aa00c22-b508-4b81-a3a1-4702453d48c2", installId: "cross-process-install", funnelId: "cross-process-funnel" }); + const result = await resumePrimitiveInstallExactlyOnce(intent, { + store: createFilePrimitiveInstallStateStore(${JSON.stringify(statePath)}, { pollMs: 5 }), + isAuthenticated: async () => true, + authenticate: async () => true, + install: async () => { appendFileSync(${JSON.stringify(installLog)}, process.pid + "\\n"); await new Promise((resolve) => setTimeout(resolve, 150)); }, + }); + console.log(result); + `; + const runChild = async (): Promise => + await new Promise((resolveChild, rejectChild) => { + const child = spawn("bun", ["-e", childSource], { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += String(chunk))); + child.stderr.on("data", (chunk) => (stderr += String(chunk))); + child.once("error", rejectChild); + child.once("exit", (code) => { + if (code === 0) resolveChild(stdout.trim()); + else rejectChild(new Error(`child exited ${code}: ${stderr}`)); + }); + }); + + try { + const results = await Promise.all([runChild(), runChild()]); + expect(results.sort()).toEqual(["already-installed", "installed"]); + expect(readFileSync(installLog, "utf8").trim().split("\n")).toHaveLength(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("never reclaims a stale-looking claim while its owner process is alive", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-catalog-live-owner-")); + const statePath = join(dir, "pending.json"); + const claimDir = `${statePath}.claim`; + const intent = createPrimitiveInstallIntent({ + itemName: "thread-message-stack", + query: "thread", + artifactId: "21c28523-7487-43e1-927d-43a7fe855859", + versionId: "1aa00c22-b508-4b81-a3a1-4702453d48c2", + installId: "live-owner-install", + }); + const install = vi.fn(async () => undefined); + try { + mkdirSync(claimDir, { recursive: true }); + writeFileSync( + join(claimDir, "owner.json"), + JSON.stringify({ pid: process.pid, token: "live-owner" }), + ); + const stale = new Date(Date.now() - 60_000); + utimesSync(claimDir, stale, stale); + await expect( + resumePrimitiveInstallExactlyOnce(intent, { + store: createFilePrimitiveInstallStateStore(statePath, { + staleClaimMs: 10, + claimWaitMs: 30, + pollMs: 5, + }), + isAuthenticated: async () => true, + authenticate: async () => true, + install, + }), + ).rejects.toThrow(/already claimed/); + expect(install).not.toHaveBeenCalled(); + expect(existsSync(claimDir)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/catalog-resume.ts b/packages/cli/src/commands/catalog-resume.ts new file mode 100644 index 0000000000..2a5ac5c00a --- /dev/null +++ b/packages/cli/src/commands/catalog-resume.ts @@ -0,0 +1,274 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +export type PrimitiveInstallStatus = + | "pending" + | "awaiting-auth" + | "installing" + | "completed" + | "cancelled" + | "failed"; + +export interface PendingPrimitiveInstall { + schemaVersion: 1; + itemName: string; + artifactId: string; + versionId: string; + funnelId: string; + installId: string; + queryFingerprint: string; + status: PrimitiveInstallStatus; +} + +export interface PrimitiveInstallStateStore { + load(): Promise; + save(state: PendingPrimitiveInstall): Promise; + acquireClaim?(): Promise; +} + +export interface PrimitiveInstallClaim { + release(): Promise; +} + +export interface CreatePrimitiveInstallIntentArgs { + itemName: string; + query: string; + artifactId: string; + versionId: string; + funnelId?: string; + installId?: string; +} + +export function filterPrimitiveCatalogItems< + T extends { name: string; title: string; description: string }, +>(items: T[], query: string): T[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return items; + return items.filter((item) => + [item.name, item.title, item.description].some((value) => + value.toLowerCase().includes(normalized), + ), + ); +} + +export function createPrimitiveInstallIntent( + args: CreatePrimitiveInstallIntentArgs, +): PendingPrimitiveInstall { + return { + schemaVersion: 1, + itemName: args.itemName, + artifactId: args.artifactId, + versionId: args.versionId, + funnelId: args.funnelId ?? randomUUID(), + installId: args.installId ?? randomUUID(), + queryFingerprint: createHash("sha256").update(args.query).digest("hex"), + status: "pending", + }; +} + +export type PrimitiveAuthenticationOutcome = boolean | "cancelled" | "failed"; + +export interface PrimitiveInstallResumeDeps { + store: PrimitiveInstallStateStore; + isAuthenticated(): Promise; + authenticate(): Promise; + install(intent: PendingPrimitiveInstall): Promise; +} + +export type PrimitiveInstallResumeResult = + | "installed" + | "already-installed" + | "cancelled" + | "failed"; + +/** + * Persist the source-owned selection before OAuth and consume that same install + * id exactly once after authentication. No authored query or message content is + * stored in the resume state. + */ +export async function resumePrimitiveInstallExactlyOnce( + intent: PendingPrimitiveInstall, + deps: PrimitiveInstallResumeDeps, +): Promise { + const claim = deps.store.acquireClaim ? await deps.store.acquireClaim() : null; + try { + const persisted = await deps.store.load(); + const terminal = terminalResumeResult(persisted, intent.installId); + if (terminal) return terminal; + + let state: PendingPrimitiveInstall = { + ...(persisted?.installId === intent.installId ? persisted : intent), + }; + await deps.store.save(state); + + const authentication = await authenticateClaimedInstall(state, deps); + if (authentication.result) return authentication.result; + state = authentication.state; + + return await installClaimedPrimitive(state, deps); + } finally { + await claim?.release(); + } +} + +function terminalResumeResult( + persisted: PendingPrimitiveInstall | null, + installId: string, +): PrimitiveInstallResumeResult | null { + if (persisted?.installId !== installId) return null; + if (persisted.status === "completed") return "already-installed"; + if (persisted.status === "cancelled") return "cancelled"; + if (persisted.status === "failed") return "failed"; + return null; +} + +async function authenticateClaimedInstall( + state: PendingPrimitiveInstall, + deps: PrimitiveInstallResumeDeps, +): Promise<{ state: PendingPrimitiveInstall; result?: "cancelled" | "failed" }> { + if (await deps.isAuthenticated()) return { state }; + const awaiting = { ...state, status: "awaiting-auth" as const }; + await deps.store.save(awaiting); + const auth = await deps.authenticate(); + if (auth === true) return { state: awaiting }; + const result = auth === "cancelled" ? "cancelled" : "failed"; + await deps.store.save({ ...awaiting, status: result }); + return { state: awaiting, result }; +} + +async function installClaimedPrimitive( + state: PendingPrimitiveInstall, + deps: PrimitiveInstallResumeDeps, +): Promise<"installed"> { + const installing = { ...state, status: "installing" as const }; + await deps.store.save(installing); + try { + await deps.install(installing); + } catch (error) { + await deps.store.save({ ...installing, status: "failed" }); + throw error; + } + await deps.store.save({ ...installing, status: "completed" }); + return "installed"; +} + +export interface FilePrimitiveInstallStateStoreOptions { + staleClaimMs?: number; + claimWaitMs?: number; + pollMs?: number; + heartbeatMs?: number; +} + +export function createFilePrimitiveInstallStateStore( + path = join(homedir(), ".hyperframes", "pending-primitive-install.json"), + options: FilePrimitiveInstallStateStoreOptions = {}, +): PrimitiveInstallStateStore { + const claimDir = `${path}.claim`; + const staleClaimMs = options.staleClaimMs ?? 30_000; + const claimWaitMs = options.claimWaitMs ?? 60_000; + const pollMs = options.pollMs ?? 25; + const heartbeatMs = options.heartbeatMs ?? Math.max(250, Math.floor(staleClaimMs / 3)); + return { + async load() { + try { + const parsed = JSON.parse(await readFile(path, "utf8")) as PendingPrimitiveInstall; + return parsed.schemaVersion === 1 ? parsed : null; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || error instanceof SyntaxError) return null; + throw error; + } + }, + async save(state) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await rename(temporaryPath, path); + }, + async acquireClaim() { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const token = randomUUID(); + const deadline = Date.now() + claimWaitMs; + while (true) { + try { + await mkdir(claimDir, { mode: 0o700 }); + await writeFile( + join(claimDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, token })}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + const heartbeat = setInterval(() => { + const now = new Date(); + void utimes(claimDir, now, now).catch(() => undefined); + }, heartbeatMs); + heartbeat.unref(); + return { + async release() { + clearInterval(heartbeat); + try { + const owner = JSON.parse(await readFile(join(claimDir, "owner.json"), "utf8")) as { + token?: string; + }; + if (owner.token === token) await rm(claimDir, { recursive: true, force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + }, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (await reclaimDeadClaim(claimDir, staleClaimMs)) continue; + if (Date.now() >= deadline) { + throw new Error("thread-message-stack install is already claimed by another process"); + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, pollMs)); + } + } + }, + }; +} + +async function reclaimDeadClaim(claimDir: string, staleClaimMs: number): Promise { + let ageMs: number; + try { + ageMs = Date.now() - (await stat(claimDir)).mtimeMs; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT"; + } + if (ageMs < staleClaimMs) return false; + + let ownerPid: number | undefined; + try { + const owner = JSON.parse(await readFile(join(claimDir, "owner.json"), "utf8")) as { + pid?: unknown; + }; + if (typeof owner.pid === "number") ownerPid = owner.pid; + } catch { + ownerPid = undefined; + } + if (ownerPid !== undefined && isProcessAlive(ownerPid)) return false; + + const abandoned = `${claimDir}.abandoned.${process.pid}.${randomUUID()}`; + try { + await rename(claimDir, abandoned); + await rm(abandoned, { recursive: true, force: true }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + return false; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} diff --git a/packages/cli/src/commands/catalog.oauth.test.ts b/packages/cli/src/commands/catalog.oauth.test.ts new file mode 100644 index 0000000000..1c4ed7b96d --- /dev/null +++ b/packages/cli/src/commands/catalog.oauth.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + select: vi.fn(), + tryResolveCredential: vi.fn(), + tryResolveOAuthCredential: vi.fn(), + startAuthorizationCodeFlow: vi.fn(), + getCurrentUser: vi.fn(), + runAdd: vi.fn(), + trackEvent: vi.fn(), + shouldTrack: vi.fn(), +})); + +vi.mock("@clack/prompts", () => ({ + select: mocks.select, + isCancel: () => false, + cancel: vi.fn(), +})); +vi.mock("../auth/index.js", () => ({ + tryResolveCredential: mocks.tryResolveCredential, + tryResolveOAuthCredential: mocks.tryResolveOAuthCredential, + startAuthorizationCodeFlow: mocks.startAuthorizationCodeFlow, + AuthClient: class { + getCurrentUser = mocks.getCurrentUser; + }, +})); +vi.mock("./add.js", () => ({ runAdd: mocks.runAdd })); +vi.mock("../telemetry/client.js", () => ({ + trackEvent: mocks.trackEvent, + shouldTrack: mocks.shouldTrack, +})); +vi.mock("../telemetry/config.js", () => ({ + readConfig: () => ({ anonymousId: "anonymous-test-id" }), +})); +vi.mock("../registry/resolver.js", () => ({ + listRegistryItems: vi.fn(async () => [ + { name: "thread-message-stack", type: "hyperframes:block" }, + ]), + loadAllItems: vi.fn(async () => [ + { + name: "thread-message-stack", + type: "hyperframes:block", + title: "Thread Message Stack", + description: "Conversation", + tags: [], + dimensions: { width: 1920, height: 1080 }, + duration: 8, + files: [], + }, + ]), +})); +vi.mock("../telemetry/primitive-funnel-state.js", () => ({ + writePrimitiveFunnelContext: vi.fn(), +})); +vi.mock("./catalog-resume.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createFilePrimitiveInstallStateStore: vi.fn(() => ({})), + resumePrimitiveInstallExactlyOnce: vi.fn(async (_intent, deps) => { + if (!(await deps.isAuthenticated())) { + const outcome = await deps.authenticate(); + if (outcome !== true) return outcome; + } + await deps.install(_intent); + return "installed"; + }), + }; +}); + +import catalogCommand from "./catalog.js"; + +describe("interactive catalog verified OAuth boundary", () => { + beforeEach(() => { + mocks.select.mockReset().mockResolvedValue("thread-message-stack"); + mocks.tryResolveCredential.mockReset().mockResolvedValue({ type: "api_key", key: "key" }); + mocks.tryResolveOAuthCredential.mockReset().mockResolvedValue(null); + mocks.startAuthorizationCodeFlow.mockReset().mockResolvedValue(undefined); + mocks.getCurrentUser.mockReset().mockResolvedValue({ email: "verified@example.com" }); + mocks.trackEvent.mockReset(); + mocks.shouldTrack.mockReset().mockReturnValue(true); + mocks.runAdd.mockReset().mockResolvedValue({ + ok: true, + name: "thread-message-stack", + type: "hyperframes:block", + written: [], + warnings: [], + snippet: "", + }); + }); + + it("does not let an API key suppress HeyGen OAuth before catalog install", async () => { + mocks.tryResolveOAuthCredential.mockResolvedValueOnce(null).mockResolvedValue({ + type: "oauth", + access_token: "oauth-token", + source: "file_json", + refreshable: false, + }); + + await catalogCommand.run!({ + args: { "human-friendly": true }, + rawArgs: [], + cmd: catalogCommand, + } as never); + + expect(mocks.startAuthorizationCodeFlow).toHaveBeenCalledTimes(1); + expect(mocks.getCurrentUser).toHaveBeenCalledWith(expect.objectContaining({ type: "oauth" })); + expect(mocks.runAdd).toHaveBeenCalledTimes(1); + }); + + it("stitches an already verified OAuth session exactly once before install", async () => { + mocks.tryResolveOAuthCredential.mockResolvedValue({ + type: "oauth", + access_token: "existing-oauth-token", + source: "file_json", + refreshable: false, + }); + mocks.runAdd.mockImplementationOnce(async () => { + const identify = mocks.trackEvent.mock.calls.filter(([name]) => name === "$identify"); + const authCompleted = mocks.trackEvent.mock.calls.filter( + ([name]) => name === "primitive_auth_completed", + ); + expect(identify).toHaveLength(1); + expect(authCompleted).toHaveLength(1); + expect(identify[0]?.[1].funnel_id).toBe(authCompleted[0]?.[1].funnel_id); + return { + ok: true, + name: "thread-message-stack", + type: "hyperframes:block", + written: [], + warnings: [], + snippet: "", + }; + }); + + await catalogCommand.run!({ + args: { "human-friendly": true }, + rawArgs: [], + cmd: catalogCommand, + } as never); + + expect(mocks.startAuthorizationCodeFlow).not.toHaveBeenCalled(); + expect(mocks.trackEvent.mock.calls.filter(([name]) => name === "$identify")).toHaveLength(1); + expect( + mocks.trackEvent.mock.calls.filter(([name]) => name === "primitive_auth_completed"), + ).toHaveLength(1); + expect(mocks.runAdd).toHaveBeenCalledTimes(1); + }); + + it("emits no identity or auth-completed events when opted out", async () => { + mocks.shouldTrack.mockReturnValue(false); + mocks.tryResolveOAuthCredential.mockResolvedValue({ + type: "oauth", + access_token: "existing-oauth-token", + source: "file_json", + refreshable: false, + }); + + await catalogCommand.run!({ + args: { "human-friendly": true }, + rawArgs: [], + cmd: catalogCommand, + } as never); + + expect(mocks.startAuthorizationCodeFlow).not.toHaveBeenCalled(); + expect(mocks.trackEvent).not.toHaveBeenCalled(); + expect(mocks.runAdd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 2bf367cec1..00931c199f 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -16,7 +16,26 @@ import { c } from "../ui/colors.js"; import { listRegistryItems, loadAllItems } from "../registry/resolver.js"; import { loadProjectConfig, DEFAULT_PROJECT_CONFIG } from "../utils/projectConfig.js"; import { resolve } from "node:path"; +import { randomUUID } from "node:crypto"; import { runAdd } from "./add.js"; +import { + createFilePrimitiveInstallStateStore, + createPrimitiveInstallIntent, + filterPrimitiveCatalogItems, + resumePrimitiveInstallExactlyOnce, +} from "./catalog-resume.js"; +import { + AuthClient, + startAuthorizationCodeFlow, + tryResolveOAuthCredential, +} from "../auth/index.js"; +import { PrimitiveFunnel } from "../telemetry/primitive-funnel.js"; +import { writePrimitiveFunnelContext } from "../telemetry/primitive-funnel-state.js"; +import { + THREAD_MESSAGE_STACK_ARTIFACT_ID, + THREAD_MESSAGE_STACK_CATALOG_DIGEST, + THREAD_MESSAGE_STACK_VERSION_ID, +} from "../registry/heygenverseCatalog.js"; export default defineCommand({ meta: { @@ -32,6 +51,10 @@ export default defineCommand({ type: "string", description: "Filter by tag (e.g. social, transition, text)", }, + query: { + type: "string", + description: "Filter by name, title, or description", + }, json: { type: "boolean", description: "Print matching items as JSON to stdout", @@ -41,6 +64,8 @@ export default defineCommand({ description: "Interactive picker — select an item to install", }, }, + // Catalog output and interactive installation intentionally share the command boundary. + // fallow-ignore-next-line complexity async run({ args }) { const json = args.json === true; const interactive = args["human-friendly"] === true; @@ -69,12 +94,15 @@ export default defineCommand({ const items = await loadAllItems(filtered, { baseUrl: config.registry }); const tagFilter = args.tag?.toLowerCase(); - const matching = tagFilter + const tagged = tagFilter ? items.filter((item) => item.tags?.some((t) => t.toLowerCase() === tagFilter)) : items; + const normalizedQuery = args.query?.trim().toLowerCase() ?? ""; + const matching = filterPrimitiveCatalogItems(tagged, normalizedQuery); if (matching.length === 0) { if (json) console.log("[]"); + else if (normalizedQuery) console.log(`No items match query "${args.query}".`); else console.log(`No items match tag "${args.tag}".`); return; } @@ -110,11 +138,72 @@ export default defineCommand({ finishCommand(0); } - const result = await runAdd({ - name: selected as string, - projectDir: dir, - skipClipboard: false, - }); + const selectedName = selected as string; + let result: Awaited> | undefined; + if (selectedName === "thread-message-stack") { + const intent = createPrimitiveInstallIntent({ + itemName: selectedName, + query: args.query ?? "", + artifactId: THREAD_MESSAGE_STACK_ARTIFACT_ID, + versionId: THREAD_MESSAGE_STACK_VERSION_ID, + }); + const funnelContext = { + funnelId: intent.funnelId, + installId: intent.installId, + artifactId: intent.artifactId, + versionId: intent.versionId, + catalogVersion: THREAD_MESSAGE_STACK_CATALOG_DIGEST, + queryFingerprint: `sha256:${intent.queryFingerprint}`, + }; + const funnel = new PrimitiveFunnel(funnelContext); + funnel.searched(); + funnel.selected(); + const outcome = await resumePrimitiveInstallExactlyOnce(intent, { + store: createFilePrimitiveInstallStateStore(), + isAuthenticated: async () => { + const credential = await tryResolveOAuthCredential(); + if (!credential) return false; + try { + const user = await new AuthClient().getCurrentUser(credential); + funnel.authCompleted(user.email ?? user.username); + return true; + } catch { + return false; + } + }, + authenticate: async () => { + funnel.authRequired(); + try { + await startAuthorizationCodeFlow(); + const credential = await tryResolveOAuthCredential(); + if (!credential) return "failed"; + const user = await new AuthClient().getCurrentUser(credential); + funnel.authCompleted(user.email ?? user.username); + return true; + } catch { + return "failed"; + } + }, + install: async () => { + result = await runAdd({ name: selectedName, projectDir: dir, skipClipboard: false }); + writePrimitiveFunnelContext(dir, funnelContext); + }, + }); + if (outcome === "failed" || outcome === "cancelled") { + funnel.installFailed( + randomUUID(), + outcome === "cancelled" ? "auth_cancelled" : "auth_failed", + ); + throw new Error(`Catalog authentication ${outcome}; no primitive was installed.`); + } + if (outcome === "installed") funnel.installSucceeded(randomUUID()); + if (!result) { + console.log(`${c.success("✓")} ${c.accent(selectedName)} was already installed.`); + return; + } + } else { + result = await runAdd({ name: selectedName, projectDir: dir, skipClipboard: false }); + } for (const warning of result.warnings) { console.warn(c.warn(`Warning: ${warning}`)); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 1c297427f4..f101f57364 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -70,6 +70,10 @@ import { stopBackgroundPreview, } from "./previewLifecycle.js"; import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; +import { + trackPrimitivePreviewFailed, + trackPrimitivePreviewSucceeded, +} from "../telemetry/primitive-funnel-command.js"; interface BrowserLaunchOptions { noOpen?: boolean; @@ -395,6 +399,7 @@ export default defineCommand({ browserGpuMode, }); } catch (error) { + trackPrimitivePreviewFailed(dir, "preview_failed"); clack.log.error(errorMessage(error)); setCommandExitCode(1); return; @@ -417,6 +422,7 @@ export default defineCommand({ remoteDebuggingPort, browserNoGpu, }); + trackPrimitivePreviewSucceeded(dir); return; } @@ -924,6 +930,7 @@ function attachStudioReadyHandler( spinner.stop(c.success("Studio running")); printStudioSummary(projectName, url, { footer: "Press Ctrl+C to stop" }); openStudioBrowser(url, projectName, projectDir, options); + trackPrimitivePreviewSucceeded(projectDir); child.stdout.removeListener("data", handleOutput); child.stderr.removeListener("data", handleOutput); } @@ -931,6 +938,7 @@ function attachStudioReadyHandler( child.stdout.on("data", handleOutput); child.stderr.on("data", handleOutput); child.on("error", (err) => { + trackPrimitivePreviewFailed(projectDir, "preview_failed"); spinner.stop(c.error("Failed to start studio")); console.error(c.dim(err.message)); }); @@ -1076,6 +1084,7 @@ async function runEmbeddedMode( options?.browserGpuMode, ); } catch (err: unknown) { + trackPrimitivePreviewFailed(dir, "preview_failed"); s.stop(c.error("Failed to start studio")); console.error(); console.error(` ${(err as Error).message}`); @@ -1091,6 +1100,7 @@ async function runEmbeddedMode( details: ["Reusing existing server. Use --force-new to start a fresh instance."], }); openStudioBrowser(url, pName, dir, options); + trackPrimitivePreviewSucceeded(dir); return; } @@ -1109,6 +1119,7 @@ async function runEmbeddedMode( footer: "Press Ctrl+C to stop", }); openStudioBrowser(url, pName, dir, options); + trackPrimitivePreviewSucceeded(dir); // Block until Ctrl+C. Node would normally exit on SIGINT, but the listening // HTTP server keeps handles open, so the event loop stays alive after the diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 78fbd65674..8d2de1c049 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -70,6 +70,10 @@ import { type HyperframesConfig, } from "../telemetry/config.js"; import { shouldTrack } from "../telemetry/client.js"; +import { + trackPrimitiveRenderFailed, + trackPrimitiveRenderSucceeded, +} from "../telemetry/primitive-funnel-command.js"; import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js"; import { bytesToMb } from "../telemetry/system.js"; import { VERSION } from "../version.js"; @@ -737,6 +741,7 @@ async function renderDocker( child.on("error", (err) => reject(err)); }); } catch (error: unknown) { + trackPrimitiveRenderFailed(projectDir, "render_failed"); handleRenderError(error, options, startTime, true, "Check Docker is running: docker info"); } @@ -747,6 +752,7 @@ async function renderDocker( // so any late throw here (telemetry flush, feedback prompt) cannot flip // the exit code. markRenderSucceeded(); + trackPrimitiveRenderSucceeded(projectDir); // Track metrics (no job object available from Docker — use a minimal stub) runPostRenderStep("trackRenderComplete", () => @@ -894,6 +900,7 @@ export async function renderLocal( try { await producer.executeRenderJob(job, projectDir, outputPath, onProgress); } catch (error: unknown) { + trackPrimitiveRenderFailed(projectDir, "render_failed"); maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet); handleRenderError( error, @@ -912,6 +919,7 @@ export async function renderLocal( // the exit code. Field signal ts=1784169760 / ts=1784171150 / ts=1784172467 // (win32/x64, CLI 0.7.58): valid MP4 on disk, exited 1 with no error print. markRenderSucceeded(); + trackPrimitiveRenderSucceeded(projectDir); maybeConsumeDeParallelRouterTrial(deParallelRouterTrialArmed, job, options.quiet); const elapsed = Date.now() - startTime; diff --git a/packages/cli/src/registry/heygenverseCatalog.test.ts b/packages/cli/src/registry/heygenverseCatalog.test.ts new file mode 100644 index 0000000000..6effbc158b --- /dev/null +++ b/packages/cli/src/registry/heygenverseCatalog.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + THREAD_MESSAGE_STACK_ARTIFACT_ID, + THREAD_MESSAGE_STACK_CANONICAL_URI, + THREAD_MESSAGE_STACK_VERSION_ID, + assertHeyGenVerseSourceDigest, + parseHeyGenVerseCatalogProjection, +} from "./heygenverseCatalog.js"; + +const projectionPath = resolve( + import.meta.dirname, + "../../../../registry/heygenverse-catalog.json", +); +const itemPath = resolve( + import.meta.dirname, + "../../../../registry/blocks/thread-message-stack/registry-item.json", +); +const sourcePath = resolve( + import.meta.dirname, + "../../../../registry/blocks/thread-message-stack/thread-message-stack.html", +); + +describe("HeyGenVerse catalog projection", () => { + it("preserves the canonical artifact/version identity and immutable digest", () => { + const projection = parseHeyGenVerseCatalogProjection(readFileSync(projectionPath, "utf8")); + expect(projection.artifactId).toBe(THREAD_MESSAGE_STACK_ARTIFACT_ID); + expect(projection.versionId).toBe(THREAD_MESSAGE_STACK_VERSION_ID); + expect(projection.canonicalUri).toBe(THREAD_MESSAGE_STACK_CANONICAL_URI); + expect(projection.records).toEqual([ + expect.objectContaining({ name: "thread-message-stack", type: "hyperframes:block" }), + ]); + }); + + it("rejects missing, altered, or divergent provenance", () => { + const source = JSON.parse(readFileSync(projectionPath, "utf8")) as Record; + expect(() => + parseHeyGenVerseCatalogProjection(JSON.stringify({ ...source, versionId: "changed" })), + ).toThrow(/version/); + expect(() => + parseHeyGenVerseCatalogProjection(JSON.stringify({ ...source, catalogDigest: "sha256:00" })), + ).toThrow(/digest/); + expect(() => + parseHeyGenVerseCatalogProjection(JSON.stringify({ ...source, artifactId: undefined })), + ).toThrow(/artifact/); + }); + + it("rejects source bytes that diverge from the projected immutable digest", () => { + const item = JSON.parse(readFileSync(itemPath, "utf8")); + const source = readFileSync(sourcePath, "utf8"); + expect(() => assertHeyGenVerseSourceDigest(item, source)).not.toThrow(); + expect(() => assertHeyGenVerseSourceDigest(item, `${source}\nchanged`)).toThrow( + /source digest/, + ); + }); +}); diff --git a/packages/cli/src/registry/heygenverseCatalog.ts b/packages/cli/src/registry/heygenverseCatalog.ts new file mode 100644 index 0000000000..9bea2266d0 --- /dev/null +++ b/packages/cli/src/registry/heygenverseCatalog.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import type { RegistryItem } from "@hyperframes/core"; + +export const THREAD_MESSAGE_STACK_ARTIFACT_ID = "21c28523-7487-43e1-927d-43a7fe855859"; +export const THREAD_MESSAGE_STACK_VERSION_ID = "1aa00c22-b508-4b81-a3a1-4702453d48c2"; +export const THREAD_MESSAGE_STACK_CANONICAL_URI = + "heygenverse://app/21c28523-7487-43e1-927d-43a7fe855859/version/1aa00c22-b508-4b81-a3a1-4702453d48c2"; +export const THREAD_MESSAGE_STACK_CATALOG_DIGEST = + "sha256:c8c5a26a43564d30f4866b35a0f182820bc6906596afb8bbbf0935d4cef22365"; + +export interface HeyGenVerseCatalogRecord { + name: "thread-message-stack"; + type: "hyperframes:block"; + sourceDigest: string; +} + +export interface HeyGenVerseCatalogProjection { + schemaVersion: 1; + artifactId: string; + versionId: string; + canonicalUri: string; + exportedAt: string; + records: HeyGenVerseCatalogRecord[]; + catalogDigest: string; +} + +function isDigest(value: unknown): value is string { + return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value); +} + +function projectionPayload( + projection: Omit, +): string { + return JSON.stringify({ + schemaVersion: projection.schemaVersion, + artifactId: projection.artifactId, + versionId: projection.versionId, + canonicalUri: projection.canonicalUri, + exportedAt: projection.exportedAt, + records: projection.records.map((record) => ({ + name: record.name, + type: record.type, + sourceDigest: record.sourceDigest, + })), + }); +} + +function catalogDigest(projection: Omit): string { + return `sha256:${createHash("sha256").update(projectionPayload(projection)).digest("hex")}`; +} + +// Strict validation intentionally keeps every immutable identity check explicit. +// fallow-ignore-next-line complexity +export function parseHeyGenVerseCatalogProjection(source: string): HeyGenVerseCatalogProjection { + let value: unknown; + try { + value = JSON.parse(source); + } catch { + throw new Error("HeyGenVerse catalog projection contains invalid JSON."); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("HeyGenVerse catalog projection must be an object."); + } + const record = value as Record; + if (record.schemaVersion !== 1) throw new Error("Unsupported catalog schema version."); + if (record.artifactId !== THREAD_MESSAGE_STACK_ARTIFACT_ID) { + throw new Error("HeyGenVerse catalog artifact identity does not match the canonical artifact."); + } + if (record.versionId !== THREAD_MESSAGE_STACK_VERSION_ID) { + throw new Error("HeyGenVerse catalog version does not match the canonical version."); + } + if (record.canonicalUri !== THREAD_MESSAGE_STACK_CANONICAL_URI) { + throw new Error("HeyGenVerse catalog canonical URI does not match the canonical version."); + } + if (typeof record.exportedAt !== "string" || Number.isNaN(Date.parse(record.exportedAt))) { + throw new Error("HeyGenVerse catalog exportedAt must be an ISO timestamp."); + } + if (!Array.isArray(record.records) || record.records.length !== 1) { + throw new Error("HeyGenVerse catalog must project exactly one thread-message-stack record."); + } + const projected = record.records[0] as Record; + if ( + projected?.name !== "thread-message-stack" || + projected.type !== "hyperframes:block" || + !isDigest(projected.sourceDigest) + ) { + throw new Error("HeyGenVerse catalog record diverges from thread-message-stack provenance."); + } + if (!isDigest(record.catalogDigest)) { + throw new Error("HeyGenVerse catalog digest is missing or malformed."); + } + const projection: HeyGenVerseCatalogProjection = { + schemaVersion: 1, + artifactId: record.artifactId, + versionId: record.versionId, + canonicalUri: record.canonicalUri, + exportedAt: record.exportedAt, + records: [ + { + name: "thread-message-stack", + type: "hyperframes:block", + sourceDigest: projected.sourceDigest, + }, + ], + catalogDigest: record.catalogDigest, + }; + const { catalogDigest: suppliedDigest, ...payload } = projection; + if (catalogDigest(payload) !== suppliedDigest) { + throw new Error("HeyGenVerse catalog digest does not match its immutable projection payload."); + } + if (suppliedDigest !== THREAD_MESSAGE_STACK_CATALOG_DIGEST) { + throw new Error("HeyGenVerse catalog digest does not match the canonical export version."); + } + return projection; +} + +export function assertHeyGenVerseProjectedItem( + item: RegistryItem, + projection: HeyGenVerseCatalogProjection, +): void { + if (item.name !== "thread-message-stack") return; + const provenance = item.provenance; + const projected = projection.records[0]; + if ( + provenance?.kind !== "heygenverse-export" || + provenance.artifactId !== projection.artifactId || + provenance.versionId !== projection.versionId || + provenance.canonicalUri !== projection.canonicalUri || + provenance.sourceDigest !== projected?.sourceDigest + ) { + throw new Error("thread-message-stack registry item diverges from its HeyGenVerse projection."); + } +} + +export function assertHeyGenVerseSourceDigest(item: RegistryItem, source: string): void { + if (item.name !== "thread-message-stack") return; + const expected = item.provenance?.sourceDigest; + const actual = `sha256:${createHash("sha256").update(source).digest("hex")}`; + if (!expected || actual !== expected) { + throw new Error("thread-message-stack source digest diverges from its immutable projection."); + } +} diff --git a/packages/cli/src/registry/installer.ts b/packages/cli/src/registry/installer.ts index 4ce4314e14..bb37c07d00 100644 --- a/packages/cli/src/registry/installer.ts +++ b/packages/cli/src/registry/installer.ts @@ -10,12 +10,20 @@ import { readFileSync, writeFileSync } from "node:fs"; import { resolve, relative, isAbsolute } from "node:path"; import type { FileTarget, RegistryItem } from "@hyperframes/core"; import { fetchItemFile, DEFAULT_REGISTRY_URL } from "./remote.js"; +import { + materializeThreadMessageStack, + THREAD_MESSAGE_STACK_ITEM_NAME, + type ThreadMessageStackData, +} from "./threadMessageStack.js"; +import { assertHeyGenVerseSourceDigest } from "./heygenverseCatalog.js"; export interface InstallOptions { /** Project root where files land. Every target resolves relative to this. */ destDir: string; /** Base URL of the registry. Defaults to the official public registry. */ baseUrl?: string; + /** Structured data materialized into the one source-owned primitive JSON seam. */ + threadMessageStackData?: ThreadMessageStackData; } export interface InstallResult { @@ -83,7 +91,13 @@ export async function installItem( const destPath = resolve(destDir, file.target); await fetchItemFile(item, file, destPath, baseUrl); if (isInstalledRegistryBlockComposition(item, file)) { - const source = readFileSync(destPath, "utf-8"); + let source = readFileSync(destPath, "utf-8"); + if (item.name === THREAD_MESSAGE_STACK_ITEM_NAME && item.provenance) { + assertHeyGenVerseSourceDigest(item, source); + } + if (item.name === THREAD_MESSAGE_STACK_ITEM_NAME && options.threadMessageStackData) { + source = materializeThreadMessageStack(source, options.threadMessageStackData); + } writeFileSync(destPath, addRegistryItemMarker(source, item), "utf-8"); } return destPath; diff --git a/packages/cli/src/registry/remote.heygenverse.test.ts b/packages/cli/src/registry/remote.heygenverse.test.ts new file mode 100644 index 0000000000..9516c2a700 --- /dev/null +++ b/packages/cli/src/registry/remote.heygenverse.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { DEFAULT_REGISTRY_URL, fetchHeyGenVerseCatalogProjection } from "./remote.js"; + +const projectionSource = readFileSync( + resolve(import.meta.dirname, "../../../../registry/heygenverse-catalog.json"), + "utf8", +); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env["HYPERFRAMES_LOCAL_REGISTRY"]; +}); + +describe("canonical HeyGenVerse projection fetch", () => { + it("cannot be redirected by a project registry URL", async () => { + const fetchMock = vi.fn(async () => new Response(projectionSource, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchHeyGenVerseCatalogProjection("https://attacker.invalid/registry"), + ).resolves.toMatchObject({ schemaVersion: 1 }); + expect(fetchMock).toHaveBeenCalledWith( + `${DEFAULT_REGISTRY_URL}/heygenverse-catalog.json`, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it("supports the exact checked-out projection through the fixed loopback developer endpoint", async () => { + process.env["HYPERFRAMES_LOCAL_REGISTRY"] = "1"; + const fetchMock = vi.fn(async () => new Response(projectionSource, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchHeyGenVerseCatalogProjection()).resolves.toMatchObject({ schemaVersion: 1 }); + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:4173/heygenverse-catalog.json", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); +}); diff --git a/packages/cli/src/registry/remote.ts b/packages/cli/src/registry/remote.ts index b7cd0bab7e..00f7c156f2 100644 --- a/packages/cli/src/registry/remote.ts +++ b/packages/cli/src/registry/remote.ts @@ -22,9 +22,14 @@ import { type RegistryItem, type RegistryManifest, } from "@hyperframes/core"; +import { + parseHeyGenVerseCatalogProjection, + type HeyGenVerseCatalogProjection, +} from "./heygenverseCatalog.js"; export const DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry"; +const LOCAL_REGISTRY_PROJECTION_URL = "http://127.0.0.1:4173/heygenverse-catalog.json"; const FETCH_TIMEOUT_MS = 10_000; @@ -101,6 +106,27 @@ export async function fetchRegistryManifest( } } +/** Fetch and validate the immutable HeyGenVerse export projection. */ +export async function fetchHeyGenVerseCatalogProjection( + _baseUrl: string = DEFAULT_REGISTRY_URL, +): Promise { + // Keep the optional parameter for API compatibility, but never let project + // configuration redirect this immutable source-of-truth projection. An + // explicit developer switch may use only the fixed loopback endpoint; the + // projection still must pass its canonical identity and digest checks. + const projectionUrl = + process.env["HYPERFRAMES_LOCAL_REGISTRY"] === "1" + ? LOCAL_REGISTRY_PROJECTION_URL + : `${DEFAULT_REGISTRY_URL}/heygenverse-catalog.json`; + const response = await fetch(projectionUrl, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`HeyGenVerse catalog projection fetch failed — HTTP ${response.status}`); + } + return parseHeyGenVerseCatalogProjection(await response.text()); +} + /** * Fetch a single item's `registry-item.json` manifest. Cached for 24h. * Throws on network failure (callers decide whether to degrade gracefully). diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts index 8ba98749a8..ff3e924bb0 100644 --- a/packages/cli/src/registry/resolver.ts +++ b/packages/cli/src/registry/resolver.ts @@ -5,7 +5,13 @@ */ import type { ItemType, RegistryItem, RegistryManifestEntry } from "@hyperframes/core"; -import { fetchItemManifest, fetchRegistryManifest, DEFAULT_REGISTRY_URL } from "./remote.js"; +import { + fetchHeyGenVerseCatalogProjection, + fetchItemManifest, + fetchRegistryManifest, + DEFAULT_REGISTRY_URL, +} from "./remote.js"; +import { assertHeyGenVerseProjectedItem } from "./heygenverseCatalog.js"; export interface ResolveOptions { baseUrl?: string; @@ -54,6 +60,7 @@ export async function loadAllItems( entries.map((e) => fetchItemManifest(e.name, e.type, baseUrl)), ); const items: RegistryItem[] = []; + let projection: Awaited> | undefined; results.forEach((r, i) => { if (r.status === "fulfilled") { items.push(r.value); @@ -62,6 +69,10 @@ export async function loadAllItems( warn(`skipped item "${name}": ${String(r.reason)}`); } }); + if (items.some((item) => item.name === "thread-message-stack")) { + projection = await fetchHeyGenVerseCatalogProjection(baseUrl); + for (const item of items) assertHeyGenVerseProjectedItem(item, projection); + } return items; } @@ -165,6 +176,12 @@ export async function resolveItemWithDependencies( visiting.add(itemName); const item = await getItem(itemName); + if (item.name === "thread-message-stack") { + const projection = await fetchHeyGenVerseCatalogProjection( + options.baseUrl ?? DEFAULT_REGISTRY_URL, + ); + assertHeyGenVerseProjectedItem(item, projection); + } for (const dep of item.registryDependencies ?? []) { await visit(dep, [...path, itemName]); } diff --git a/packages/cli/src/registry/threadMessageStack.integration.test.ts b/packages/cli/src/registry/threadMessageStack.integration.test.ts new file mode 100644 index 0000000000..a71838fb87 --- /dev/null +++ b/packages/cli/src/registry/threadMessageStack.integration.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { bundleToSingleHtml } from "@hyperframes/core/compiler"; +import { registerPreviewRoutes } from "../../../studio-server/src/routes/preview.js"; +import type { StudioApiAdapter } from "../../../studio-server/src/types.js"; +import { compileForRender } from "../../../producer/src/services/htmlCompiler.js"; +import { + createRenderJob, + executeRenderJob, +} from "../../../producer/src/services/renderOrchestrator.js"; +import { resolveConfig } from "../../../producer/src/config.js"; +import type { RegistryItem } from "@hyperframes/core"; +import { installItem } from "./installer.js"; +import { parseThreadMessageStackData } from "./threadMessageStack.js"; +import { lintProject } from "../utils/lintProject.js"; +import { DEFAULT_CHECK_OPTIONS, runCheckPipeline } from "../utils/checkPipeline.js"; + +const tempDirs: string[] = []; +const sourcePath = resolve( + import.meta.dirname, + "../../../../registry/blocks/thread-message-stack/thread-message-stack.html", +); + +const item: RegistryItem = { + name: "thread-message-stack", + type: "hyperframes:block", + title: "Thread Message Stack", + description: "A source-owned editable conversation stack.", + dimensions: { width: 1920, height: 1080 }, + duration: 8, + files: [ + { + path: "thread-message-stack.html", + target: "compositions/thread-message-stack.html", + type: "hyperframes:composition", + }, + ], +}; + +afterEach(() => { + vi.unstubAllGlobals(); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function createProject(): string { + const dir = mkdtempSync(join(tmpdir(), "hf-thread-stack-")); + tempDirs.push(dir); + mkdirSync(join(dir, "compositions"), { recursive: true }); + writeFileSync( + join(dir, "index.html"), + ` +
+
+
+ + `, + ); + return dir; +} + +function previewAdapter(projectDir: string): StudioApiAdapter { + return { + listProjects: () => [], + resolveProject: async (id) => ({ id, dir: projectDir }), + bundle: async () => await bundleToSingleHtml(projectDir), + lint: async () => ({ findings: [] }), + runtimeUrl: "/api/runtime.js", + rendersDir: () => join(projectDir, "renders"), + startRender: () => ({ + id: "job-1", + status: "rendering", + progress: 0, + outputPath: join(projectDir, "renders/out.mp4"), + }), + }; +} + +function stubRegistrySource(source: string): void { + const realFetch = globalThis.fetch; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL, init?: RequestInit) => { + if ( + String(input).includes("test.invalid/blocks/thread-message-stack/thread-message-stack.html") + ) { + return new Response(source, { status: 200 }); + } + return await realFetch(input, init); + }), + ); +} + +// End-to-end boundary coverage intentionally stays together so every temp project is cleaned. +// fallow-ignore-next-line unit-size +describe("thread-message-stack install to preview/render parity", () => { + for (const count of [1, 6, 20]) { + it(`crosses the real installer, Studio preview route, and producer compiler for ${count} messages`, async () => { + const projectDir = createProject(); + const source = readFileSync(sourcePath, "utf8"); + stubRegistrySource(source); + const messages = Array.from({ length: count }, (_, index) => ({ + side: index % 2 === 0 ? ("incoming" as const) : ("outgoing" as const), + text: index === count - 1 ? `${"long ".repeat(400)}${index}` : `message-${index}`, + sender: index === 0 ? "" : undefined, + })); + + await installItem(item, { + destDir: projectDir, + baseUrl: "https://test.invalid", + threadMessageStackData: { messages, stagger: 0, hold: 0 }, + }); + const installedPath = join(projectDir, "compositions/thread-message-stack.html"); + expect(parseThreadMessageStackData(readFileSync(installedPath, "utf8")).messages).toEqual( + messages, + ); + + const app = new Hono(); + registerPreviewRoutes(app, previewAdapter(projectDir)); + const previewResponse = await app.request("http://localhost/projects/demo/preview"); + const previewHtml = await previewResponse.text(); + expect(previewResponse.status).toBe(200); + + const producerDir = join(projectDir, "producer-output"); + mkdirSync(producerDir, { recursive: true }); + const producerHtml = await compileForRender( + projectDir, + join(projectDir, "index.html"), + producerDir, + ); + for (const message of messages) { + expect(previewHtml).toContain(message.text); + expect(producerHtml.html).toContain(message.text); + } + expect(previewHtml).toContain("thread-message-stack"); + expect(previewHtml).toContain("__timelines"); + expect(producerHtml.html).toContain("thread-message-stack"); + expect(producerHtml.html).toContain("__timelines"); + expect(producerHtml.html).toContain("hidden data-hf-primitive-data"); + expect(producerHtml.html).not.toMatch( + /function\(document, gsap, window, __hyperframes\) \{\s*\{"messages"/, + ); + }); + } + + it("produces a real deterministic frame chain and MP4 from the installed source", async () => { + const projectDir = createProject(); + const source = readFileSync(sourcePath, "utf8"); + stubRegistrySource(source); + await installItem(item, { + destDir: projectDir, + baseUrl: "https://test.invalid", + threadMessageStackData: { + messages: [ + { side: "incoming", text: "first", sender: "" }, + { side: "outgoing", text: "second" }, + ], + stagger: 0, + hold: 0, + }, + }); + const lint = await lintProject(projectDir); + expect(lint.totalErrors).toBe(0); + const check = await runCheckPipeline( + { + dir: projectDir, + name: "thread-message-stack-smoke", + indexPath: join(projectDir, "index.html"), + }, + { ...DEFAULT_CHECK_OPTIONS, samples: 3, contrast: false }, + ); + expect(check.ok, JSON.stringify(check, null, 2)).toBe(true); + expect(check.runtime.errorCount).toBe(0); + const output = join(projectDir, "thread-message-stack.mp4"); + const job = createRenderJob({ + fps: 1, + quality: "draft", + workers: 1, + producerConfig: resolveConfig({ forceScreenshot: true }), + }); + await executeRenderJob(job, projectDir, output); + expect(job.status).toBe("complete"); + expect(job.outcome).toBe("completed"); + expect(job.warnings).toEqual([]); + expect(job.perfSummary?.totalFrames).toBe(8); + expect(statSync(output).size).toBeGreaterThan(0); + }, 120_000); +}); diff --git a/packages/cli/src/registry/threadMessageStack.test.ts b/packages/cli/src/registry/threadMessageStack.test.ts new file mode 100644 index 0000000000..09eb0f9d1e --- /dev/null +++ b/packages/cli/src/registry/threadMessageStack.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + THREAD_MESSAGE_STACK_DATA_SELECTOR, + materializeThreadMessageStack, + parseThreadMessageStackData, + type ThreadMessageStackData, +} from "./threadMessageStack.js"; + +const SOURCE = ` + + + +`; + +function data(count: number): ThreadMessageStackData { + return { + messages: Array.from({ length: count }, (_, index) => ({ + side: index % 2 === 0 ? "incoming" : "outgoing", + text: `message-${index}`, + sender: index === 0 ? "Support" : "", + })), + stagger: 0.42, + hold: 1.25, + }; +} + +function outsideDataBlock(source: string): string { + return source.replace(/(]*data-hf-primitive-data[^>]*>)[\s\S]*?(<\/div>)/, "$1__DATA__$2"); +} + +describe("thread-message-stack materialization", () => { + for (const count of [1, 6, 20]) { + it(`preserves all ${count} messages in order without truncation`, () => { + const materialized = materializeThreadMessageStack(SOURCE, data(count)); + const parsed = parseThreadMessageStackData(materialized); + + expect(parsed.messages).toHaveLength(count); + expect(parsed.messages.map((message) => message.text)).toEqual( + data(count).messages.map((message) => message.text), + ); + expect(outsideDataBlock(materialized)).toBe(outsideDataBlock(SOURCE)); + expect(materialized).toContain(THREAD_MESSAGE_STACK_DATA_SELECTOR); + }); + } + + it("preserves deliberate long content and empty optional scalar values", () => { + const longText = "A".repeat(8_192); + const payload: ThreadMessageStackData = { + messages: [{ side: "incoming", text: longText, sender: "" }], + stagger: 0, + hold: 0, + }; + + const parsed = parseThreadMessageStackData(materializeThreadMessageStack(SOURCE, payload)); + expect(parsed).toEqual(payload); + }); + + it("rejects malformed, oversized, or ambiguous data blocks", () => { + expect(() => + materializeThreadMessageStack(SOURCE, { + messages: [{ side: "middle" as "incoming", text: "bad" }], + }), + ).toThrow(/side/); + expect(() => + materializeThreadMessageStack(SOURCE, { + messages: [{ side: "incoming", text: "x".repeat(8_193) }], + }), + ).toThrow(/8192/); + expect(() => materializeThreadMessageStack(SOURCE, { messages: [] })).toThrow(/at least one/); + expect(() => materializeThreadMessageStack(SOURCE + SOURCE, data(1))).toThrow(/exactly one/); + }); +}); diff --git a/packages/cli/src/registry/threadMessageStack.ts b/packages/cli/src/registry/threadMessageStack.ts new file mode 100644 index 0000000000..41c57b3be1 --- /dev/null +++ b/packages/cli/src/registry/threadMessageStack.ts @@ -0,0 +1,117 @@ +export const THREAD_MESSAGE_STACK_ITEM_NAME = "thread-message-stack"; +export const THREAD_MESSAGE_STACK_DATA_SELECTOR = "data-hf-primitive-data"; + +const MAX_MESSAGES = 100; +const MAX_TEXT_LENGTH = 8_192; +const MAX_SENDER_LENGTH = 256; +const DATA_BLOCK_RE = + /(]*\bdata-hf-primitive-data(?:\s|=|>))[^>]*>)([\s\S]*?)(<\/div>)/gi; + +export interface ThreadMessageStackMessage { + side: "incoming" | "outgoing"; + text: string; + sender?: string; +} + +export interface ThreadMessageStackData { + messages: ThreadMessageStackMessage[]; + stagger?: number; + hold?: number; +} + +function assertFiniteScalar(value: unknown, name: "stagger" | "hold"): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 60) { + throw new Error(`thread-message-stack ${name} must be a finite number from 0 to 60.`); + } +} + +// Validation enumerates every bounded authored field before any file mutation. +// fallow-ignore-next-line complexity +export function validateThreadMessageStackData(value: unknown): ThreadMessageStackData { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("thread-message-stack data must be an object."); + } + const record = value as Record; + if (!Array.isArray(record.messages) || record.messages.length === 0) { + throw new Error("thread-message-stack messages must contain at least one message."); + } + if (record.messages.length > MAX_MESSAGES) { + throw new Error(`thread-message-stack supports at most ${MAX_MESSAGES} messages per install.`); + } + + // Each authored message field is independently bounded before materialization. + // fallow-ignore-next-line complexity + const messages = record.messages.map((candidate, index): ThreadMessageStackMessage => { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) { + throw new Error(`thread-message-stack messages[${index}] must be an object.`); + } + const message = candidate as Record; + if (message.side !== "incoming" && message.side !== "outgoing") { + throw new Error(`thread-message-stack messages[${index}].side must be incoming or outgoing.`); + } + if (typeof message.text !== "string") { + throw new Error(`thread-message-stack messages[${index}].text must be a string.`); + } + if (message.text.length > MAX_TEXT_LENGTH) { + throw new Error( + `thread-message-stack messages[${index}].text must be at most ${MAX_TEXT_LENGTH} characters.`, + ); + } + if (message.sender !== undefined && typeof message.sender !== "string") { + throw new Error(`thread-message-stack messages[${index}].sender must be a string.`); + } + if (typeof message.sender === "string" && message.sender.length > MAX_SENDER_LENGTH) { + throw new Error( + `thread-message-stack messages[${index}].sender must be at most ${MAX_SENDER_LENGTH} characters.`, + ); + } + return { + side: message.side, + text: message.text, + ...(message.sender !== undefined ? { sender: message.sender } : {}), + }; + }); + + if (record.stagger !== undefined) assertFiniteScalar(record.stagger, "stagger"); + if (record.hold !== undefined) assertFiniteScalar(record.hold, "hold"); + return { + messages, + ...(record.stagger !== undefined ? { stagger: record.stagger } : {}), + ...(record.hold !== undefined ? { hold: record.hold } : {}), + }; +} + +function locateDataBlock(source: string): RegExpMatchArray { + const matches = [...source.matchAll(DATA_BLOCK_RE)]; + if (matches.length !== 1) { + throw new Error( + `thread-message-stack source must contain exactly one ${THREAD_MESSAGE_STACK_DATA_SELECTOR} JSON block; found ${matches.length}.`, + ); + } + return matches[0]!; +} + +export function materializeThreadMessageStack( + source: string, + input: ThreadMessageStackData, +): string { + const data = validateThreadMessageStackData(input); + locateDataBlock(source); + const serialized = JSON.stringify(data).replace(/ { + return `${open}${serialized}${close}`; + }); +} + +export function parseThreadMessageStackData(source: string): ThreadMessageStackData { + const match = locateDataBlock(source); + try { + return validateThreadMessageStackData(JSON.parse(match[2] ?? "")); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error("thread-message-stack data block contains invalid JSON."); + } + throw error; + } +} diff --git a/packages/cli/src/registry/threadMessageStackAuthorization.test.ts b/packages/cli/src/registry/threadMessageStackAuthorization.test.ts new file mode 100644 index 0000000000..5fab4aebc5 --- /dev/null +++ b/packages/cli/src/registry/threadMessageStackAuthorization.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setupTempAuthEnv } from "../auth/_test-utils.js"; +import { writeStore } from "../auth/store.js"; +import { authorizeThreadMessageStackInstall } from "./threadMessageStackAuthorization.js"; + +describe("thread-message-stack verified OAuth authorization", () => { + let fixture: Awaited>; + + beforeEach(async () => { + fixture = await setupTempAuthEnv("hf-thread-oauth-"); + }); + + afterEach(async () => await fixture.restore()); + + it("does not accept an API key as OAuth and preserves cancellation", async () => { + process.env["HEYGEN_API_KEY"] = "api-key-only"; + const authenticate = vi.fn(async () => "cancelled" as const); + const verify = vi.fn(); + + await expect(authorizeThreadMessageStackInstall({ authenticate, verify })).resolves.toBe( + "cancelled", + ); + expect(authenticate).toHaveBeenCalledTimes(1); + expect(verify).not.toHaveBeenCalled(); + }); + + it("requires the OAuth token to pass a current-user verification", async () => { + await writeStore({ oauth: { access_token: "oauth-access-token" } }); + const verify = vi.fn(async () => ({ email: "verified@example.com" })); + + await expect(authorizeThreadMessageStackInstall({ verify })).resolves.toBe("authorized"); + expect(verify).toHaveBeenCalledWith( + expect.objectContaining({ type: "oauth", access_token: "oauth-access-token" }), + ); + }); + + it("fails closed when OAuth identity verification fails", async () => { + await writeStore({ oauth: { access_token: "oauth-access-token" } }); + + await expect( + authorizeThreadMessageStackInstall({ + verify: async () => { + throw new Error("unverified"); + }, + }), + ).resolves.toBe("failed"); + }); +}); diff --git a/packages/cli/src/registry/threadMessageStackAuthorization.ts b/packages/cli/src/registry/threadMessageStackAuthorization.ts new file mode 100644 index 0000000000..41b8d6193e --- /dev/null +++ b/packages/cli/src/registry/threadMessageStackAuthorization.ts @@ -0,0 +1,53 @@ +import { + AuthClient, + startAuthorizationCodeFlow, + tryResolveOAuthCredential, +} from "../auth/index.js"; +import type { ResolvedCredential, UserInfo } from "../auth/index.js"; + +export type ThreadMessageStackAuthorizationOutcome = + | "authorized" + | "api-key-only" + | "cancelled" + | "failed"; + +interface AuthorizationDeps { + authenticate?: () => Promise; + verify?: (credential: ResolvedCredential) => Promise; +} + +async function defaultAuthenticate(): Promise { + try { + await startAuthorizationCodeFlow(); + return true; + } catch { + return "failed"; + } +} + +/** + * Require a persisted HeyGen OAuth session and verify it against the current-user + * endpoint. Environment/file API keys never satisfy this primitive boundary. + */ +export async function authorizeThreadMessageStackInstall( + deps: AuthorizationDeps = {}, +): Promise { + const authenticate = deps.authenticate ?? defaultAuthenticate; + const verify = + deps.verify ?? (async (credential) => await new AuthClient().getCurrentUser(credential)); + let credential = await tryResolveOAuthCredential(); + + if (!credential) { + const outcome = await authenticate(); + if (outcome !== true) return outcome; + credential = await tryResolveOAuthCredential(); + if (!credential) return "api-key-only"; + } + + try { + await verify(credential); + return "authorized"; + } catch { + return "failed"; + } +} diff --git a/packages/cli/src/telemetry/primitive-funnel-command.ts b/packages/cli/src/telemetry/primitive-funnel-command.ts new file mode 100644 index 0000000000..35e45070ad --- /dev/null +++ b/packages/cli/src/telemetry/primitive-funnel-command.ts @@ -0,0 +1,40 @@ +import { PrimitiveFunnel, type PrimitiveFunnelErrorCode } from "./primitive-funnel.js"; +import { claimPrimitiveFunnelEvent, readPrimitiveFunnelContext } from "./primitive-funnel-state.js"; + +function emitProjectTerminal( + projectDir: string, + suffix: "preview" | "render", + emit: (funnel: PrimitiveFunnel, eventId: string) => void, +): void { + const context = readPrimitiveFunnelContext(projectDir); + if (!context) return; + const eventId = `${context.installId}:${suffix}`; + if (!claimPrimitiveFunnelEvent(projectDir, eventId)) return; + emit(new PrimitiveFunnel(context), eventId); +} + +export function trackPrimitivePreviewSucceeded(projectDir: string): void { + emitProjectTerminal(projectDir, "preview", (funnel, eventId) => funnel.previewSucceeded(eventId)); +} + +export function trackPrimitivePreviewFailed( + projectDir: string, + errorCode: PrimitiveFunnelErrorCode, +): void { + emitProjectTerminal(projectDir, "preview", (funnel, eventId) => + funnel.previewFailed(eventId, errorCode), + ); +} + +export function trackPrimitiveRenderSucceeded(projectDir: string): void { + emitProjectTerminal(projectDir, "render", (funnel, eventId) => funnel.renderSucceeded(eventId)); +} + +export function trackPrimitiveRenderFailed( + projectDir: string, + errorCode: PrimitiveFunnelErrorCode, +): void { + emitProjectTerminal(projectDir, "render", (funnel, eventId) => + funnel.renderFailed(eventId, errorCode), + ); +} diff --git a/packages/cli/src/telemetry/primitive-funnel-state.ts b/packages/cli/src/telemetry/primitive-funnel-state.ts new file mode 100644 index 0000000000..c817badf37 --- /dev/null +++ b/packages/cli/src/telemetry/primitive-funnel-state.ts @@ -0,0 +1,79 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import type { PrimitiveFunnelContext } from "./primitive-funnel.js"; + +const FUNNEL_STATE_DIR = ".hyperframes"; +const FUNNEL_STATE_FILE = "primitive-funnel.json"; + +interface PersistedPrimitiveFunnelContext extends PrimitiveFunnelContext { + emittedEventIds: string[]; +} + +function statePath(projectDir: string): string { + return join(projectDir, FUNNEL_STATE_DIR, FUNNEL_STATE_FILE); +} + +function isContext(value: unknown): value is PrimitiveFunnelContext { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return [ + "funnelId", + "installId", + "artifactId", + "versionId", + "catalogVersion", + "queryFingerprint", + ].every((key) => typeof record[key] === "string" && record[key].length > 0); +} + +export function readPrimitiveFunnelContext(projectDir: string): PrimitiveFunnelContext | null { + try { + const value: unknown = JSON.parse(readFileSync(statePath(projectDir), "utf8")); + if (!isContext(value)) return null; + const { funnelId, installId, artifactId, versionId, catalogVersion, queryFingerprint } = value; + return { funnelId, installId, artifactId, versionId, catalogVersion, queryFingerprint }; + } catch { + return null; + } +} + +function writeState(projectDir: string, state: PersistedPrimitiveFunnelContext): void { + const directory = join(projectDir, FUNNEL_STATE_DIR); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const path = statePath(projectDir); + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + writeFileSync(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + renameSync(temporaryPath, path); +} + +export function writePrimitiveFunnelContext( + projectDir: string, + context: PrimitiveFunnelContext, +): void { + writeState(projectDir, { ...context, emittedEventIds: [] }); +} + +/** Atomically claim a stable terminal id before enqueueing cross-command telemetry. */ +export function claimPrimitiveFunnelEvent(projectDir: string, eventId: string): boolean { + try { + const value: unknown = JSON.parse(readFileSync(statePath(projectDir), "utf8")); + if (!isContext(value)) return false; + const record = value as PrimitiveFunnelContext & { emittedEventIds?: unknown }; + const emittedEventIds = Array.isArray(record.emittedEventIds) + ? record.emittedEventIds.filter( + (candidate): candidate is string => typeof candidate === "string", + ) + : []; + if (emittedEventIds.includes(eventId)) return false; + const context = readPrimitiveFunnelContext(projectDir); + if (!context) return false; + writeState(projectDir, { ...context, emittedEventIds: [...emittedEventIds, eventId] }); + return true; + } catch { + return false; + } +} diff --git a/packages/cli/src/telemetry/primitive-funnel.test.ts b/packages/cli/src/telemetry/primitive-funnel.test.ts new file mode 100644 index 0000000000..c1269b9217 --- /dev/null +++ b/packages/cli/src/telemetry/primitive-funnel.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const trackEvent = vi.fn(); +const shouldTrack = vi.fn(() => true); +vi.mock("./client.js", () => ({ + trackEvent: (...args: unknown[]) => trackEvent(...args), + shouldTrack: () => shouldTrack(), +})); + +const { PrimitiveFunnel } = await import("./primitive-funnel.js"); +const { claimPrimitiveFunnelEvent, readPrimitiveFunnelContext, writePrimitiveFunnelContext } = + await import("./primitive-funnel-state.js"); + +// Funnel contract assertions intentionally share one mocked telemetry boundary. +// fallow-ignore-next-line unit-size +describe("primitive discovery funnel", () => { + beforeEach(() => { + trackEvent.mockClear(); + shouldTrack.mockReturnValue(true); + }); + + it("uses one stable funnel id, identifies once, and deduplicates terminal ids", () => { + const funnel = new PrimitiveFunnel({ + funnelId: "funnel-1", + installId: "install-1", + artifactId: "artifact-1", + versionId: "version-1", + catalogVersion: "catalog-1", + queryFingerprint: "sha256:query", + }); + funnel.searched(); + funnel.selected(); + funnel.authRequired(); + funnel.authCompleted("account-1"); + funnel.authCompleted("account-1"); + funnel.installSucceeded("event-install"); + funnel.installSucceeded("event-install"); + funnel.previewSucceeded("event-preview"); + funnel.renderFailed("event-render", "capture_failed"); + + const calls = trackEvent.mock.calls; + expect(calls.filter(([name]) => name === "$identify")).toHaveLength(1); + expect(calls.filter(([name]) => name === "primitive_auth_completed")).toHaveLength(1); + expect(calls.find(([name]) => name === "$identify")?.[2]).toBe("account-1"); + expect(calls.filter(([name]) => name === "primitive_install_succeeded")).toHaveLength(1); + expect(calls.every(([, props]) => props.funnel_id === "funnel-1")).toBe(true); + }); + + it("emits only allowlisted non-content properties and bounded errors", () => { + const funnel = new PrimitiveFunnel({ + funnelId: "funnel-2", + installId: "install-2", + artifactId: "artifact-2", + versionId: "version-2", + catalogVersion: "catalog-2", + queryFingerprint: "sha256:query", + }); + funnel.installFailed("event-1", "invalid_payload"); + + const payload = trackEvent.mock.lastCall?.[1] as Record; + expect(Object.keys(payload).sort()).toEqual( + [ + "artifact_id", + "catalog_version", + "error_code", + "event_id", + "funnel_id", + "install_id", + "query_fingerprint", + "version_id", + ].sort(), + ); + expect(JSON.stringify(payload)).not.toMatch( + /messages|html|css|javascript|asset|token|raw_error/, + ); + }); + + it("does not enqueue or identify when telemetry is opted out", () => { + shouldTrack.mockReturnValue(false); + const funnel = new PrimitiveFunnel({ + funnelId: "funnel-3", + installId: "install-3", + artifactId: "artifact-3", + versionId: "version-3", + catalogVersion: "catalog-3", + queryFingerprint: "sha256:query", + }); + funnel.searched(); + funnel.authCompleted("account-3"); + funnel.renderSucceeded("event-3"); + expect(trackEvent).not.toHaveBeenCalled(); + }); + + it("bridges commands with only allowlisted non-content metadata", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-funnel-")); + const context = { + funnelId: "funnel-4", + installId: "install-4", + artifactId: "artifact-4", + versionId: "version-4", + catalogVersion: "catalog-4", + queryFingerprint: "sha256:query", + }; + writePrimitiveFunnelContext(dir, context); + expect(readPrimitiveFunnelContext(dir)).toEqual(context); + expect(claimPrimitiveFunnelEvent(dir, "install-4:preview")).toBe(true); + expect(claimPrimitiveFunnelEvent(dir, "install-4:preview")).toBe(false); + expect(readFileSync(join(dir, ".hyperframes", "primitive-funnel.json"), "utf8")).not.toMatch( + /messages|html|css|javascript|asset|token|raw_error/, + ); + rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/packages/cli/src/telemetry/primitive-funnel.ts b/packages/cli/src/telemetry/primitive-funnel.ts new file mode 100644 index 0000000000..8098989242 --- /dev/null +++ b/packages/cli/src/telemetry/primitive-funnel.ts @@ -0,0 +1,112 @@ +import { shouldTrack, trackEvent } from "./client.js"; +import { readConfig } from "./config.js"; + +export type PrimitiveFunnelErrorCode = + | "invalid_payload" + | "auth_failed" + | "auth_cancelled" + | "install_failed" + | "preview_failed" + | "compile_failed" + | "capture_failed" + | "render_failed"; + +export interface PrimitiveFunnelContext { + funnelId: string; + installId: string; + artifactId: string; + versionId: string; + catalogVersion: string; + queryFingerprint: string; +} + +type PrimitiveFunnelBaseProperties = { + funnel_id: string; + install_id: string; + artifact_id: string; + version_id: string; + catalog_version: string; + query_fingerprint: string; +}; + +/** Privacy-safe telemetry for one catalog-selection lifecycle. */ +export class PrimitiveFunnel { + readonly #base: PrimitiveFunnelBaseProperties; + readonly #terminalEventIds = new Set(); + #identified = false; + + constructor(context: PrimitiveFunnelContext) { + this.#base = { + funnel_id: context.funnelId, + install_id: context.installId, + artifact_id: context.artifactId, + version_id: context.versionId, + catalog_version: context.catalogVersion, + query_fingerprint: context.queryFingerprint, + }; + } + + searched(): void { + this.#track("primitive_searched"); + } + + selected(): void { + this.#track("primitive_selected"); + } + + authRequired(): void { + this.#track("primitive_auth_required"); + } + + authCompleted(accountId?: string): void { + if (!shouldTrack() || this.#identified) return; + this.#identified = true; + if (accountId) { + trackEvent( + "$identify", + { ...this.#base, $anon_distinct_id: readConfig().anonymousId }, + accountId, + ); + } + trackEvent("primitive_auth_completed", this.#base); + } + + installSucceeded(eventId: string): void { + this.#trackTerminal("primitive_install_succeeded", eventId); + } + + installFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode): void { + this.#trackTerminal("primitive_install_failed", eventId, errorCode); + } + + previewSucceeded(eventId: string): void { + this.#trackTerminal("primitive_preview_succeeded", eventId); + } + + previewFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode): void { + this.#trackTerminal("primitive_preview_failed", eventId, errorCode); + } + + renderSucceeded(eventId: string): void { + this.#trackTerminal("primitive_render_succeeded", eventId); + } + + renderFailed(eventId: string, errorCode: PrimitiveFunnelErrorCode): void { + this.#trackTerminal("primitive_render_failed", eventId, errorCode); + } + + #track(name: string): void { + if (!shouldTrack()) return; + trackEvent(name, this.#base); + } + + #trackTerminal(name: string, eventId: string, errorCode?: PrimitiveFunnelErrorCode): void { + if (!shouldTrack() || this.#terminalEventIds.has(eventId)) return; + this.#terminalEventIds.add(eventId); + trackEvent(name, { + ...this.#base, + event_id: eventId, + ...(errorCode ? { error_code: errorCode } : {}), + }); + } +} diff --git a/packages/core/schemas/heygenverse-catalog.json b/packages/core/schemas/heygenverse-catalog.json new file mode 100644 index 0000000000..ecefc9a361 --- /dev/null +++ b/packages/core/schemas/heygenverse-catalog.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hyperframes.heygen.com/schema/heygenverse-catalog.json", + "title": "HyperFrames HeyGenVerse Catalog Projection", + "type": "object", + "required": [ + "schemaVersion", + "artifactId", + "versionId", + "canonicalUri", + "exportedAt", + "records", + "catalogDigest" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "artifactId": { "type": "string", "format": "uuid" }, + "versionId": { "type": "string", "format": "uuid" }, + "canonicalUri": { "type": "string", "pattern": "^heygenverse://app/" }, + "exportedAt": { "type": "string", "format": "date-time" }, + "catalogDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "records": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "type", "sourceDigest"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "type": { "const": "hyperframes:block" }, + "sourceDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + } + } + } + } +} diff --git a/packages/core/schemas/registry-item.json b/packages/core/schemas/registry-item.json index a2051a5bf2..b34f3d2d93 100644 --- a/packages/core/schemas/registry-item.json +++ b/packages/core/schemas/registry-item.json @@ -128,6 +128,18 @@ "relatedSkill": { "type": "string", "minLength": 1 + }, + "provenance": { + "type": "object", + "required": ["kind", "artifactId", "versionId", "canonicalUri", "sourceDigest"], + "additionalProperties": false, + "properties": { + "kind": { "const": "heygenverse-export" }, + "artifactId": { "type": "string", "format": "uuid" }, + "versionId": { "type": "string", "format": "uuid" }, + "canonicalUri": { "type": "string", "pattern": "^heygenverse://app/" }, + "sourceDigest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" } + } } }, "allOf": [ diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 94797a9aaa..454ce10b9c 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -34,6 +34,14 @@ export interface RegistryItemPreview { poster?: string; } +interface HeyGenVerseExportProvenance { + kind: "heygenverse-export"; + artifactId: string; + versionId: string; + canonicalUri: string; + sourceDigest: string; +} + /** Fields common to every registry item, regardless of type. */ interface RegistryItemBase { /** JSON Schema URL — `https://hyperframes.heygen.com/schema/registry-item.json`. */ @@ -66,6 +74,8 @@ interface RegistryItemBase { preview?: RegistryItemPreview; /** Related skill slug (e.g. `hyperframes-captions`) — shown in docs. */ relatedSkill?: string; + /** Read-only identity of a versioned HeyGenVerse export projected into this registry. */ + provenance?: HeyGenVerseExportProvenance; } /** Full-project example — scaffolded by `hyperframes init --example `. */ diff --git a/registry/blocks/thread-message-stack/registry-item.json b/registry/blocks/thread-message-stack/registry-item.json new file mode 100644 index 0000000000..c62f8cb84a --- /dev/null +++ b/registry/blocks/thread-message-stack/registry-item.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/registry-item.json", + "name": "thread-message-stack", + "type": "hyperframes:block", + "title": "Thread Message Stack", + "description": "A source-owned editable conversation stack with arbitrary incoming and outgoing messages", + "tags": ["social", "conversation", "messages"], + "dimensions": { "width": 1920, "height": 1080 }, + "duration": 8, + "provenance": { + "kind": "heygenverse-export", + "artifactId": "21c28523-7487-43e1-927d-43a7fe855859", + "versionId": "1aa00c22-b508-4b81-a3a1-4702453d48c2", + "canonicalUri": "heygenverse://app/21c28523-7487-43e1-927d-43a7fe855859/version/1aa00c22-b508-4b81-a3a1-4702453d48c2", + "sourceDigest": "sha256:5d0c56cbf9dc0af525548acb813a910098de2c902fcc84262fc3100af171f934" + }, + "files": [ + { + "path": "thread-message-stack.html", + "target": "compositions/thread-message-stack.html", + "type": "hyperframes:composition" + } + ] +} diff --git a/registry/blocks/thread-message-stack/thread-message-stack.html b/registry/blocks/thread-message-stack/thread-message-stack.html new file mode 100644 index 0000000000..fba9c66431 --- /dev/null +++ b/registry/blocks/thread-message-stack/thread-message-stack.html @@ -0,0 +1,235 @@ + + + + + Thread Message Stack + + + + + diff --git a/registry/heygenverse-catalog.json b/registry/heygenverse-catalog.json new file mode 100644 index 0000000000..40a860c058 --- /dev/null +++ b/registry/heygenverse-catalog.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/heygenverse-catalog.json", + "schemaVersion": 1, + "artifactId": "21c28523-7487-43e1-927d-43a7fe855859", + "versionId": "1aa00c22-b508-4b81-a3a1-4702453d48c2", + "canonicalUri": "heygenverse://app/21c28523-7487-43e1-927d-43a7fe855859/version/1aa00c22-b508-4b81-a3a1-4702453d48c2", + "exportedAt": "2026-08-05T03:56:31Z", + "records": [ + { + "name": "thread-message-stack", + "type": "hyperframes:block", + "sourceDigest": "sha256:5d0c56cbf9dc0af525548acb813a910098de2c902fcc84262fc3100af171f934" + } + ], + "catalogDigest": "sha256:c8c5a26a43564d30f4866b35a0f182820bc6906596afb8bbbf0935d4cef22365" +} diff --git a/registry/registry.json b/registry/registry.json index bff47f2948..f009cd15c9 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -706,6 +706,10 @@ { "name": "beat-freeze-cut", "type": "hyperframes:block" + }, + { + "name": "thread-message-stack", + "type": "hyperframes:block" } ] } diff --git a/scripts/catalog-preview-temp.test.ts b/scripts/catalog-preview-temp.test.ts new file mode 100644 index 0000000000..540c56149e --- /dev/null +++ b/scripts/catalog-preview-temp.test.ts @@ -0,0 +1,20 @@ +import { statSync, rmSync } from "node:fs"; +import { basename } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createCatalogPreviewTempDir } from "./catalog-preview-temp.js"; + +describe("catalog preview temporary directory", () => { + it("atomically creates unique owner-only directories", () => { + const first = createCatalogPreviewTempDir("thread-message-stack"); + const second = createCatalogPreviewTempDir("thread-message-stack"); + try { + expect(first).not.toBe(second); + expect(basename(first)).toMatch(/^hf-catalog-thread-message-stack-/); + expect(statSync(first).mode & 0o777).toBe(0o700); + expect(statSync(second).mode & 0o777).toBe(0o700); + } finally { + rmSync(first, { recursive: true, force: true }); + rmSync(second, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/catalog-preview-temp.ts b/scripts/catalog-preview-temp.ts new file mode 100644 index 0000000000..8e7f555378 --- /dev/null +++ b/scripts/catalog-preview-temp.ts @@ -0,0 +1,8 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Atomically allocate an owner-only preview directory under the OS temp root. */ +export function createCatalogPreviewTempDir(itemName: string): string { + return mkdtempSync(join(tmpdir(), `hf-catalog-${itemName}-`)); +} diff --git a/scripts/generate-catalog-previews.test.ts b/scripts/generate-catalog-previews.test.ts new file mode 100644 index 0000000000..263c71a5fe --- /dev/null +++ b/scripts/generate-catalog-previews.test.ts @@ -0,0 +1,35 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const previewPath = resolve(repoRoot, "docs/images/catalog/blocks/thread-message-stack.png"); + +afterEach(() => rmSync(previewPath, { force: true })); + +describe("catalog preview raw block boundary", () => { + it("renders thread-message-stack through the exact Catalog Previews command", () => { + rmSync(previewPath, { force: true }); + const result = spawnSync( + "bunx", + [ + "tsx", + "scripts/generate-catalog-previews.ts", + "--only", + "thread-message-stack", + "--skip-video", + ], + { cwd: repoRoot, encoding: "utf8", timeout: 120_000 }, + ); + const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).toBe(0); + expect(output).not.toContain("✗ thread-message-stack"); + expect(output).not.toContain("[Browser:ERROR]"); + expect(output).not.toContain("timelines not registered"); + expect(output).not.toContain("sub_timeline_readiness_timeout"); + expect(output).toContain("✓ thread-message-stack.png"); + expect(existsSync(previewPath)).toBe(true); + }, 125_000); +}); diff --git a/scripts/generate-catalog-previews.ts b/scripts/generate-catalog-previews.ts index df262f67de..93ab251f95 100644 --- a/scripts/generate-catalog-previews.ts +++ b/scripts/generate-catalog-previews.ts @@ -29,7 +29,6 @@ import { writeFileSync, } from "node:fs"; import { join, resolve, dirname } from "node:path"; -import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; // Import from source — bun workspace linking doesn't resolve for scripts outside packages/. import { @@ -44,6 +43,7 @@ import { } from "../packages/producer/src/index.js"; import { compileForRender } from "../packages/producer/src/services/htmlCompiler.js"; import { resolveContainedCopies } from "./registry-target-paths.mjs"; +import { createCatalogPreviewTempDir } from "./catalog-preview-temp.js"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); @@ -150,9 +150,24 @@ function mirrorRegistryTargets(projectDir: string): void { } } +/** + * Registry primitive payloads are inert text, but HTML formatters may wrap prose + * inside JSON strings. Normalize only control-whitespace runs in the temporary + * preview copy; the immutable registry source and installed caller payload stay + * byte-for-byte untouched. + */ +function normalizePrimitiveDataForPreview(content: string): string { + return content.replace( + /(]*data-hf-primitive-data[^>]*>)([\s\S]*?)(<\/div>)/g, + (_match, open: string, payload: string, close: string) => { + const normalized = payload.replace(/\s*[\r\n]+\s*/g, " "); + return `${open}${normalized}${close}`; + }, + ); +} + async function prepareProjectDir(item: CatalogItem): Promise { - const tmpDir = join(tmpdir(), `hf-catalog-${item.name}-${Date.now()}`); - mkdirSync(tmpDir, { recursive: true }); + const tmpDir = createCatalogPreviewTempDir(item.name); cpSync(item.sourceDir, tmpDir, { recursive: true }); mirrorRegistryTargets(tmpDir); @@ -161,9 +176,13 @@ async function prepareProjectDir(item: CatalogItem): Promise { // If the entry file is a standalone HTML (has its own timeline registration), // just rename it to index.html. Otherwise create a wrapper. if (!existsSync(join(tmpDir, "index.html")) && existsSync(join(tmpDir, item.entryFile))) { - const entryContent = readFileSync(join(tmpDir, item.entryFile), "utf-8"); + const entryPath = join(tmpDir, item.entryFile); + const rawEntryContent = readFileSync(entryPath, "utf-8"); + const entryContent = normalizePrimitiveDataForPreview(rawEntryContent); + if (entryContent !== rawEntryContent) writeFileSync(entryPath, entryContent, "utf-8"); const hasTimeline = entryContent.includes("__timelines"); - if (hasTimeline) { + const isTemplateTransport = /)/i.test(entryContent); + if (hasTimeline && !isTemplateTransport) { // Standalone block — copy to index.html and render directly. // For social overlays with transparent backgrounds, inject a dark bg // so the overlay card is visible against something. @@ -272,20 +291,8 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise const framesDir = join(projectDir, "_thumb_frames"); mkdirSync(framesDir, { recursive: true }); - const fileServer = await createFileServer({ - projectDir, - port: 0, - fps: { num: 30, den: 1 }, - }); + const { fileServer, session } = await openThumbnailSession(projectDir, framesDir, width, height); try { - const session = await createCaptureSession(fileServer.url, framesDir, { - width, - height, - fps: { num: 30, den: 1 }, - format: "png", - }); - await initializeSession(session); - let duration: number; try { duration = await getCompositionDuration(session); @@ -298,14 +305,39 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise const result = await captureFrame(session, 0, captureTime); cpSync(result.path, join(outDir, `${item.name}.png`)); console.log(` ✓ ${item.name}.png (${result.captureTimeMs}ms)`); - - await closeCaptureSession(session); } finally { + await closeCaptureSession(session).catch(() => undefined); fileServer.close(); rmSync(framesDir, { recursive: true, force: true }); } } +async function openThumbnailSession( + projectDir: string, + framesDir: string, + width: number, + height: number, +) { + const fileServer = await createFileServer({ + projectDir, + port: 0, + fps: { num: 30, den: 1 }, + }); + try { + const session = await createCaptureSession(fileServer.url, framesDir, { + width, + height, + fps: { num: 30, den: 1 }, + format: "png", + }); + await initializeSession(session); + return { fileServer, session }; + } catch (error) { + fileServer.close(); + throw error; + } +} + async function generateVideo(item: CatalogItem, projectDir: string): Promise { const outDir = outputDir(item.kind); mkdirSync(outDir, { recursive: true });