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
65 changes: 65 additions & 0 deletions packages/cli/src/commands/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@ import { dirname, join } from "node:path";
import { tmpdir } from "node:os";

const publishState = vi.hoisted(() => ({ publish: vi.fn() }));
const authState = vi.hoisted(() => ({ tryResolveCredential: vi.fn().mockResolvedValue(null) }));
const clackState = vi.hoisted(() => ({ confirm: vi.fn() }));

vi.mock("../utils/publishProject.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils/publishProject.js")>()),
publishProjectArchive: publishState.publish,
}));

vi.mock("../auth/index.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../auth/index.js")>()),
tryResolveCredential: authState.tryResolveCredential,
}));

vi.mock("@clack/prompts", async (importOriginal) => ({
...(await importOriginal<typeof import("@clack/prompts")>()),
confirm: clackState.confirm,
}));

import publishCommand, { parseUpdateTarget } from "./publish.js";

describe("parseUpdateTarget", () => {
Expand Down Expand Up @@ -90,3 +102,56 @@ describe("publish default-entry preflight", () => {
expect(output).toContain("publish accepts project directories, not individual HTML files");
});
});

describe("publish consent notice", () => {
async function runConsent(credential: unknown): Promise<string> {
const project = mkdtempSync(join(tmpdir(), "hf-publish-consent-"));
writeFileSync(
join(project, "index.html"),
`<html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="5"><div class="clip" data-start="0" data-duration="5">Visible</div></div></body></html>`,
);
publishState.publish.mockReset();
authState.tryResolveCredential.mockReset().mockResolvedValue(credential);
// Declining the prompt stops the run right after the notice — no network, no upload.
clackState.confirm.mockReset().mockResolvedValue(false);
const lines: string[] = [];
const log = vi.spyOn(console, "log").mockImplementation((...parts: unknown[]) => {
lines.push(parts.map(String).join(" "));
});

try {
await publishCommand.run?.({ args: { dir: project, proxy: false } } as never);
expect(publishState.publish).not.toHaveBeenCalled();
return lines.join("\n");
} finally {
log.mockRestore();
rmSync(project, { recursive: true, force: true });
}
}

it("offers the claim step when publishing anonymously", async () => {
const output = await runConsent(null);

expect(output).toContain("claim it after authenticating");
});

it("tells a signed-in publisher there is no claim link", async () => {
const output = await runConsent({ type: "api_key", key: "k", source: "env" });

expect(output).toContain("you own it on publish and there is no claim link");
expect(output).not.toContain("claim it after authenticating");
});

it.each([
["anonymous", null],
["signed in", { type: "api_key", key: "k", source: "env" }],
])("discloses the exposure before uploading (%s)", async (_label, credential) => {
// The consent prompt is the only place the user is told the artifact is world-readable:
// the result block prints no exposure line on the owned branch. Dropping it from either
// branch means someone approves an upload without being told who can reach it.
const output = await runConsent(credential);

expect(output).toContain("creates a stable public URL");
expect(output).toContain("Anyone with the URL can open the published project");
});
});
14 changes: 12 additions & 2 deletions packages/cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,24 @@ export default defineCommand({
}
}

// Resolved once: the consent notice and the --update/--space gate must agree on
// whether this publish is authenticated.
const credential = await tryResolveCredential();

if (args.yes !== true) {
console.log();
console.log(
` ${c.bold("hyperframes publish uploads this project and creates a stable public URL.")}`,
);
// Both branches must state the exposure — the published project is readable by anyone
// holding the URL whether or not it is owned. Only the claim half differs: an
// anonymous publish gets a claim token, an authenticated one is owned on creation, so
// promising a claim step to a signed-in user sends them looking for a token the server
// never issued.
console.log(
` ${c.dim("Anyone with the URL can open the published project and claim it after authenticating.")}`,
credential === null
? ` ${c.dim("Anyone with the URL can open the published project and claim it after authenticating.")}`
: ` ${c.dim("Anyone with the URL can open the published project. You are signed in, so you own it on publish and there is no claim link.")}`,
);
console.log();
const approved = await clack.confirm({ message: "Publish this project?" });
Expand All @@ -147,7 +158,6 @@ export default defineCommand({
// --update / --space only take effect for an authenticated owner. Fail loudly rather
// than silently minting a fresh URL — the exact failure mode this feature removes.
if (updateTarget || spaceOverride) {
const credential = await tryResolveCredential();
if (!credential) {
console.log();
console.log(
Expand Down
Loading