Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions docs/schema/registry-item.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/auth/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OAuthCredential | null> {
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<Awaited<ReturnType<typeof readStore>>["credentials"]["oauth"]>,
now: Date,
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/commands/add.oauth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
71 changes: 65 additions & 6 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -134,11 +143,19 @@ async function installAll(
installPlan: RegistryItem[],
destDir: string,
baseUrl: string | undefined,
requestedName: string,
threadMessageStackData?: ThreadMessageStackData,
): Promise<string[]> {
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) {
Expand All @@ -161,6 +178,19 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
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.
Expand Down Expand Up @@ -195,7 +225,13 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
}));

// 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]!;
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Loading
Loading