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
3 changes: 2 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ const hasJsonFlag = process.argv.includes("--json");
// Captured references — populated when the lazy imports resolve.
// Used in exit handlers where dynamic import() is unsafe (beforeExit loops,
// exit handler is synchronous-only).
let _flush: (() => Promise<void>) | undefined;
// Resolves to whether the batch was acknowledged; this exit path ignores it.
let _flush: (() => Promise<unknown>) | undefined;
let _flushSync: (() => void) | undefined;
let _trackCliError:
| ((props: {
Expand Down
66 changes: 64 additions & 2 deletions packages/cli/src/commands/add.oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,25 @@ const mocks = vi.hoisted(() => ({
authorize: vi.fn(),
install: vi.fn(),
resolve: vi.fn(),
trackEvent: vi.fn(),
shouldTrack: vi.fn(() => true),
writePrimitiveFunnelContext: vi.fn(),
}));

vi.mock("../registry/threadMessageStackAuthorization.js", () => ({
authorizeThreadMessageStackInstall: mocks.authorize,
}));
vi.mock("../registry/installer.js", () => ({ installItem: mocks.install }));
vi.mock("../telemetry/client.js", () => ({
trackEvent: mocks.trackEvent,
shouldTrack: mocks.shouldTrack,
}));
vi.mock("../telemetry/config.js", () => ({
readConfig: () => ({ anonymousId: "anonymous-direct-add" }),
}));
vi.mock("../telemetry/primitive-funnel-state.js", () => ({
writePrimitiveFunnelContext: mocks.writePrimitiveFunnelContext,
}));
vi.mock("../registry/resolver.js", () => ({
resolveItemWithDependencies: mocks.resolve,
resolveItemsByTag: vi.fn(async () => []),
Expand Down Expand Up @@ -45,31 +58,80 @@ describe("direct add thread-message-stack OAuth boundary", () => {
mocks.resolve.mockReset().mockResolvedValue([item]);
mocks.install.mockReset().mockResolvedValue({ written: [join(projectDir, "stack.html")] });
mocks.authorize.mockReset();
mocks.trackEvent.mockReset();
mocks.shouldTrack.mockReset().mockReturnValue(true);
mocks.writePrimitiveFunnelContext.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);
mocks.authorize.mockImplementation(async (deps) => {
deps.onAuthStarted?.();
return 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();
expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([
"primitive_auth_started",
"primitive_auth_failed",
]);
expect(mocks.writePrimitiveFunnelContext).not.toHaveBeenCalled();
},
);

it("downloads and materializes exactly once after verified HeyGen OAuth succeeds", async () => {
mocks.authorize.mockResolvedValue("authorized");
mocks.authorize.mockImplementation(async (deps) => {
deps.onAuthStarted?.();
deps.onVerified?.({ email: "verified@example.com" }, "oauth");
return "authorized";
});
mocks.install.mockImplementationOnce(async () => {
expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([
"primitive_auth_started",
"$identify",
"primitive_auth_completed",
"primitive_install_started",
]);
return { written: [join(projectDir, "stack.html")] };
});

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);
expect(mocks.writePrimitiveFunnelContext).toHaveBeenCalledTimes(1);
const persisted = mocks.writePrimitiveFunnelContext.mock.calls[0]?.[1];
expect(persisted).toMatchObject({
primitiveId: "thread-message-stack",
artifactId: expect.any(String),
versionId: expect.any(String),
catalogVersion: expect.any(String),
funnelId: expect.any(String),
installId: expect.any(String),
});
expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([
"primitive_auth_started",
"$identify",
"primitive_auth_completed",
"primitive_install_started",
"primitive_install_completed",
]);
const installEvents = mocks.trackEvent.mock.calls.filter(([name]) =>
String(name).startsWith("primitive_install_"),
);
expect(installEvents[0]?.[1].funnel_id).toBe(installEvents[1]?.[1].funnel_id);
expect(installEvents[1]?.[1]).toMatchObject({
duration_ms: expect.any(Number),
event_id: `${persisted.installId}:install-completed`,
});
});
});
147 changes: 105 additions & 42 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const examples: Example[] = [
["Skip the clipboard copy (CI/headless)", "hyperframes add shader-wipe --no-clipboard"],
];

import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { resolve, relative } from "node:path";
import { ITEM_TYPE_DIRS, type RegistryItem } from "@hyperframes/core";
Expand All @@ -30,10 +31,18 @@ import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
projectConfigPath,
type ProjectConfig,
writeProjectConfig,
} from "../utils/projectConfig.js";
import { copyToClipboard } from "../utils/clipboard.js";
import { authorizeThreadMessageStackInstall } from "../registry/threadMessageStackAuthorization.js";
import { PrimitiveFunnel, type PrimitiveFunnelContext } 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";

// ── Target-path resolution ──────────────────────────────────────────────────
// `registry-item.json` files specify `target` paths relative to the project
Expand Down Expand Up @@ -91,6 +100,11 @@ export interface RunAddArgs {
cliVersion?: string;
/** Caller-owned messages materialized only for thread-message-stack. */
threadMessageStackData?: ThreadMessageStackData;
/** Caller-owned catalog session; direct add creates one without search/selection side effects. */
primitiveFunnelSession?: {
context: PrimitiveFunnelContext;
funnel: PrimitiveFunnel;
};
}

export interface RunAddResult {
Expand Down Expand Up @@ -167,64 +181,40 @@ async function installAll(
return written;
}

export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
const projectDir = resolve(opts.projectDir);

// 1. Load (or write default) project config.
let config = loadProjectConfig(projectDir);
const hasConfig = existsSync(projectConfigPath(projectDir));
if (!hasConfig && existsSync(resolve(projectDir, "index.html"))) {
writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG);
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.
async function resolveRequestedItem(
name: string,
registry: string,
): Promise<{ resolved: RegistryItem[]; item: RegistryItem }> {
let resolved: RegistryItem[];
try {
resolved = await resolveItemWithDependencies(opts.name, { baseUrl: config.registry });
resolved = await resolveItemWithDependencies(name, { baseUrl: registry });
} catch (err) {
throw new AddError(err instanceof Error ? err.message : String(err), "unknown-item");
}
// `resolveItemWithDependencies` always pushes the requested item last (or throws),
// so the final element is the item the user asked for.
const item = resolved[resolved.length - 1]!;

if (item.type === "hyperframes:example") {
throw new AddError(
`"${item.name}" is an example — use \`hyperframes init <dir> --example ${item.name}\` instead.`,
"example-type",
);
}
return { resolved, item };
}

// 3. Compatibility-gate every item we're about to install (dependencies
// included) before writing anything.
async function resolveAndInstall(
opts: RunAddArgs,
projectDir: string,
config: ProjectConfig,
): Promise<RunAddResult> {
const { resolved, item } = await resolveRequestedItem(opts.name, config.registry);
const warnings = assertCompatibleOrThrow(resolved, opts.cliVersion);

// 4. Remap targets per project config — each item by its own type.
const installPlan: RegistryItem[] = resolved.map((resolvedItem) => ({
...resolvedItem,
files: resolvedItem.files.map((f) => ({
...f,
target: remapTarget(resolvedItem, f.target, config.paths),
})),
}));

// 5. Install — dependencies first, requested item last.
const written = await installAll(
installPlan,
projectDir,
Expand All @@ -233,15 +223,12 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
opts.threadMessageStackData,
);

// 6. Build include snippet + clipboard copy for the requested item.
const itemForInstall = installPlan[installPlan.length - 1]!;
const primaryFile =
itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ??
itemForInstall.files.find((f) => f.type === "hyperframes:composition") ??
itemForInstall.files[0];
const snippetTargetRel = primaryFile?.target ?? "";
const snippet = buildSnippet(item, snippetTargetRel);
const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false;
const snippet = buildSnippet(item, primaryFile?.target ?? "");

return {
ok: true,
Expand All @@ -251,11 +238,87 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
written,
installed: installPlan.map((planItem) => planItem.name),
snippet,
clipboardCopied,
clipboardCopied: !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false,
warnings,
};
}

export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
const projectDir = resolve(opts.projectDir);
let primitiveSession = opts.primitiveFunnelSession;
let installStartedAt = 0;

// 1. Load (or write default) project config.
let config = loadProjectConfig(projectDir);
const hasConfig = existsSync(projectConfigPath(projectDir));
if (!hasConfig && existsSync(resolve(projectDir, "index.html"))) {
writeProjectConfig(projectDir, DEFAULT_PROJECT_CONFIG);
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") {
if (!primitiveSession) {
const context: PrimitiveFunnelContext = {
funnelId: randomUUID(),
installId: randomUUID(),
primitiveId: "thread-message-stack",
artifactId: THREAD_MESSAGE_STACK_ARTIFACT_ID,
versionId: THREAD_MESSAGE_STACK_VERSION_ID,
catalogVersion: THREAD_MESSAGE_STACK_CATALOG_DIGEST,
queryFingerprint: `sha256:${createHash("sha256").update("").digest("hex")}`,
};
primitiveSession = { context, funnel: new PrimitiveFunnel(context) };
}
const authStartedAt = performance.now();
const authorization = await authorizeThreadMessageStackInstall({
onAuthStarted: () => primitiveSession?.funnel.authStarted(),
onVerified: (user, authState) =>
primitiveSession?.funnel.authCompleted(
user.email ?? user.username,
authState,
performance.now() - authStartedAt,
),
});
if (authorization !== "authorized") {
primitiveSession.funnel.authFailed(
`${primitiveSession.context.installId}:auth-failed`,
authorization === "cancelled" ? "auth_cancelled" : "auth_failed",
performance.now() - authStartedAt,
);
throw new AddError(
`thread-message-stack requires verified HeyGen OAuth (${authorization}); no source was downloaded or materialized.`,
"oauth-required",
);
}
primitiveSession.funnel.installStarted();
installStartedAt = performance.now();
}

try {
const result = await resolveAndInstall(opts, projectDir, config);
if (primitiveSession) {
writePrimitiveFunnelContext(projectDir, primitiveSession.context);
primitiveSession.funnel.installCompleted(
`${primitiveSession.context.installId}:install-completed`,
performance.now() - installStartedAt,
);
}
return result;
} catch (error) {
primitiveSession?.funnel.installFailed(
`${primitiveSession.context.installId}:install-failed`,
error instanceof AddError && error.code === "install-failed"
? "install_failed"
: "invalid_payload",
performance.now() - installStartedAt,
);
throw error;
}
}

// ── Command ─────────────────────────────────────────────────────────────────

export default defineCommand({
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/commands/catalog.oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,25 @@ describe("interactive catalog verified OAuth boundary", () => {
expect(mocks.startAuthorizationCodeFlow).toHaveBeenCalledTimes(1);
expect(mocks.getCurrentUser).toHaveBeenCalledWith(expect.objectContaining({ type: "oauth" }));
expect(mocks.runAdd).toHaveBeenCalledTimes(1);
expect(mocks.runAdd).toHaveBeenCalledWith(
expect.objectContaining({
primitiveFunnelSession: expect.objectContaining({
context: expect.objectContaining({ primitiveId: "thread-message-stack" }),
}),
}),
);
expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([
"primitive_catalog_searched",
"primitive_catalog_result_selected",
"primitive_auth_started",
"$identify",
"primitive_auth_completed",
]);
expect(
mocks.trackEvent.mock.calls.find(
([name]) => name === "primitive_catalog_result_selected",
)?.[1],
).toMatchObject({ result_rank: 1, auth_state: "anonymous" });
});

it("stitches an already verified OAuth session exactly once before install", async () => {
Expand Down Expand Up @@ -145,6 +164,12 @@ describe("interactive catalog verified OAuth boundary", () => {
mocks.trackEvent.mock.calls.filter(([name]) => name === "primitive_auth_completed"),
).toHaveLength(1);
expect(mocks.runAdd).toHaveBeenCalledTimes(1);
expect(mocks.trackEvent.mock.calls.map(([name]) => name)).toEqual([
"primitive_catalog_searched",
"primitive_catalog_result_selected",
"$identify",
"primitive_auth_completed",
]);
});

it("emits no identity or auth-completed events when opted out", async () => {
Expand Down
Loading
Loading