This commit is contained in:
Miguel Ángel
2026-08-30 13:01:41 -04:00
committed by GitHub
2 changed files with 77 additions and 2 deletions
+65
View File
@@ -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", () => {
@@ -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");
});
});
+12 -2
View File
@@ -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?" });
@@ -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(