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.
This commit is contained in:
Miguel Ángel
2026-06-30 11:54:10 -07:00
committed by GitHub
parent bdd0084c2a
commit 856bb0980f
3 changed files with 115 additions and 4 deletions
@@ -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"), "<html></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"), "<html></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
+15 -3
View File
@@ -383,9 +383,11 @@ async function publishProjectArchiveDirect(
apiBaseUrl: string,
title: string,
archive: PublishArchiveResult,
isPublic: boolean,
): Promise<PublishedProjectResponse> {
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<PublishedProjectResponse | null> {
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<PublishedProjectResponse> {
export interface PublishOptions {
public?: boolean;
}
export async function publishProjectArchive(
projectDir: string,
opts: PublishOptions = {},
): Promise<PublishedProjectResponse> {
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);
}