From 856bb0980fc9f85244cd1d0c8233cb7390642b0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 30 Jun 2026 11:54:10 -0700 Subject: [PATCH] feat(cli): add --public flag to publish (#1815) Opt-in --public flag on `hyperframes publish` sends is_public to the publish endpoints (staged complete body and direct multipart form) so a claimed project's studio session can be created public instead of the default private. Absent the flag the request shape is unchanged. --- packages/cli/src/commands/publish.ts | 8 +- packages/cli/src/utils/publishProject.test.ts | 93 +++++++++++++++++++ packages/cli/src/utils/publishProject.ts | 18 +++- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 260eb082d..df263d87c 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -13,6 +13,7 @@ import { publishProjectArchive } from "../utils/publishProject.js"; export const examples: Example[] = [ ["Publish the current project with a public URL", "hyperframes publish"], ["Publish a specific directory", "hyperframes publish ./my-video"], + ["Make the claimed project public to anyone", "hyperframes publish --public"], ["Skip the consent prompt (scripts)", "hyperframes publish --yes"], ]; @@ -29,6 +30,11 @@ export default defineCommand({ description: "Skip the publish confirmation prompt", default: false, }, + public: { + type: "boolean", + description: "Make the claimed project public to anyone, not just the claimer", + default: false, + }, }, async run({ args }) { const rawArg = args.dir; @@ -66,7 +72,7 @@ export default defineCommand({ publishSpinner.start("Uploading project..."); try { - const published = await publishProjectArchive(dir); + const published = await publishProjectArchive(dir, { public: args.public === true }); const claimUrl = new URL(published.url); claimUrl.searchParams.set("claim_token", published.claimToken); publishSpinner.stop(c.success("Project published")); diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index 6e812eda7..9cebf4e07 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -453,6 +453,99 @@ describe("publishProjectArchive", () => { } }); + it("sends is_public in the staged complete body only when public is requested", async () => { + const dir = makeProjectDir(); + const stagedFetch = () => + vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + upload_url: "https://s3.example.com/upload", + upload_key: "ephemeral_store/hyperframes/project_uploads/upload-1/demo.zip", + upload_headers: { "content-type": "application/zip" }, + content_type: "application/zip", + }, + }), + { status: 200 }, + ), + ) + .mockResolvedValueOnce(new Response(null, { status: 200 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + project_id: "hfp_123", + title: "demo", + file_count: 1, + url: "https://hyperframes.dev/p/hfp_123", + claim_token: "claim-token", + }, + }), + { status: 200 }, + ), + ); + + try { + writeFileSync(join(dir, "index.html"), "", "utf-8"); + + const publicFetch = stagedFetch(); + vi.stubGlobal("fetch", publicFetch); + await publishProjectArchive(dir, { public: true }); + const publicCompleteBody = JSON.parse(publicFetch.mock.calls[2]![1].body); + expect(publicCompleteBody.is_public).toBe(true); + + const defaultFetch = stagedFetch(); + vi.stubGlobal("fetch", defaultFetch); + await publishProjectArchive(dir); + const defaultCompleteBody = JSON.parse(defaultFetch.mock.calls[2]![1].body); + expect(defaultCompleteBody).not.toHaveProperty("is_public"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("sends is_public in the direct multipart form only when public is requested", async () => { + const dir = makeProjectDir(); + const directFetch = () => + vi + .fn() + .mockResolvedValueOnce(new Response("not found", { status: 404 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + project_id: "hfp_123", + title: "demo", + file_count: 1, + url: "https://hyperframes.dev/p/hfp_123", + claim_token: "claim-token", + }, + }), + { status: 200 }, + ), + ); + + try { + writeFileSync(join(dir, "index.html"), "", "utf-8"); + + const publicFetch = directFetch(); + vi.stubGlobal("fetch", publicFetch); + await publishProjectArchive(dir, { public: true }); + const publicForm = publicFetch.mock.calls[1]![1].body as FormData; + expect(publicForm.get("is_public")).toBe("true"); + + const defaultFetch = directFetch(); + vi.stubGlobal("fetch", defaultFetch); + await publishProjectArchive(dir); + const defaultForm = defaultFetch.mock.calls[1]![1].body as FormData; + expect(defaultForm.get("is_public")).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("does not fall back to multipart when a staged S3 upload fails", async () => { const dir = makeProjectDir(); const fetchMock = vi diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index c47799cf1..147047857 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -383,9 +383,11 @@ async function publishProjectArchiveDirect( apiBaseUrl: string, title: string, archive: PublishArchiveResult, + isPublic: boolean, ): Promise { const body = new FormData(); body.set("title", title); + if (isPublic) body.set("is_public", "true"); body.set( "file", new File([archiveArrayBuffer(archive)], `${title}.zip`, { type: PUBLISH_CONTENT_TYPE }), @@ -414,6 +416,7 @@ async function publishProjectArchiveStaged( apiBaseUrl: string, title: string, archive: PublishArchiveResult, + isPublic: boolean, ): Promise { const fileName = `${title}.zip`; const uploadResponse = await fetch(`${apiBaseUrl}/v1/hyperframes/projects/publish/upload`, { @@ -459,6 +462,7 @@ async function publishProjectArchiveStaged( upload_key: stagedUpload.uploadKey, file_name: fileName, title, + ...(isPublic ? { is_public: true } : {}), }), headers: { "content-type": "application/json", @@ -476,11 +480,19 @@ async function publishProjectArchiveStaged( return publishedProject; } -export async function publishProjectArchive(projectDir: string): Promise { +export interface PublishOptions { + public?: boolean; +} + +export async function publishProjectArchive( + projectDir: string, + opts: PublishOptions = {}, +): Promise { + const isPublic = opts.public === true; const title = basename(projectDir); const archive = createPublishArchive(projectDir); const apiBaseUrl = getPublishApiBaseUrl(); - const stagedResult = await publishProjectArchiveStaged(apiBaseUrl, title, archive); + const stagedResult = await publishProjectArchiveStaged(apiBaseUrl, title, archive, isPublic); if (stagedResult) return stagedResult; - return publishProjectArchiveDirect(apiBaseUrl, title, archive); + return publishProjectArchiveDirect(apiBaseUrl, title, archive, isPublic); }