From 123a66f64d0f8cb42fd1a7ceb489960229ee80c2 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Fri, 9 Oct 2026 08:16:17 +0800 Subject: [PATCH 1/3] fix(cli): include external video posters in project archives --- packages/cli/src/utils/publishProject.test.ts | 67 ++++++++++++++++++- packages/cli/src/utils/publishProject.ts | 8 ++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index da3ea5421cb..83b2b998332 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; +import { dirname, join, posix, relative } from "node:path"; import AdmZip from "adm-zip"; const authMocks = vi.hoisted(() => ({ @@ -214,6 +214,69 @@ async function runAuthenticatedPublish( } describe("createPublishArchive", () => { + it.each(["index.html", "compositions/scene.html"])( + "includes external video posters referenced from %s", + (entryPath) => { + const projectDir = makeProjectDir(); + const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); + try { + const htmlDir = join(projectDir, dirname(entryPath)); + mkdirSync(htmlDir, { recursive: true }); + if (entryPath !== "index.html") { + writeFileSync(join(projectDir, "index.html"), ""); + } + const posterPath = stageExternalAsset(extDir, htmlDir, "poster.png", "PNG_DATA"); + const videoPath = posix.relative(posix.dirname(entryPath), "clip.mp4"); + const html = ``; + writeFileSync(join(projectDir, entryPath), html); + writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA"); + + const archive = createPublishArchive(projectDir); + const zip = new AdmZip(archive.buffer); + const posterEntries = zip + .getEntries() + .filter((entry) => entry.entryName.startsWith("_ext/") && entry.name === "poster.png"); + + expect(posterEntries).toHaveLength(1); + const archivedPoster = posterEntries[0]!; + expect(archivedPoster.getData()).toEqual(Buffer.from("PNG_DATA")); + expect(zip.readAsText(entryPath)).toContain( + `poster="${posix.relative(posix.dirname(entryPath), archivedPoster.entryName)}"`, + ); + expect(zip.readAsText(entryPath)).toContain(`src="${videoPath}"`); + expect(archive.fileCount).toBe(entryPath === "index.html" ? 3 : 4); + expect(readFileSync(join(projectDir, entryPath), "utf-8")).toBe(html); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(extDir, { recursive: true, force: true }); + } + }, + ); + + it("preserves internal, remote, and inline video posters", () => { + const projectDir = makeProjectDir(); + try { + const posters = [ + "poster.png", + "https://cdn.example.com/poster.png", + "data:image/png;base64,AA==", + ]; + const html = `${posters.map((poster) => ``).join("")}`; + writeFileSync(join(projectDir, "index.html"), html); + writeFileSync(join(projectDir, "poster.png"), "PNG_DATA"); + writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA"); + + const archive = createPublishArchive(projectDir); + const zip = new AdmZip(archive.buffer); + + expect(archive.fileCount).toBe(3); + expect(zip.readAsText("index.html")).toBe(html); + expect(zip.getEntries().some((entry) => entry.entryName.startsWith("_ext/"))).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + it("packages the project and skips hidden files and node_modules", () => { const dir = makeProjectDir(); try { diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index 339cbf7cbfc..c3c615c200a 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -475,8 +475,12 @@ function rewriteStyleBlocks( function localizeHtmlEntry(ctx: ExternalAssetContext, entryPath: string, content: Buffer): void { const referrerAbsDir = resolve(ctx.absProjectDir, dirname(entryPath)); const { document } = parseHTML(content.toString("utf-8")); - const attrsChanged = rewriteHtmlAttributes(document, referrerAbsDir, entryPath, (val, dir) => - tryResolveExternal(ctx, val, dir), + const attrsChanged = rewriteHtmlAttributes( + document, + referrerAbsDir, + entryPath, + (val, dir) => tryResolveExternal(ctx, val, dir), + { attrs: ["src", "href", "poster"] }, ); const stylesChanged = rewriteStyleBlocks(ctx, document, referrerAbsDir, entryPath); if (attrsChanged || stylesChanged) { From d15fdcdbe6d774f524f564781c029a224a747531 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 13:40:02 +0800 Subject: [PATCH 2/3] chore: prepare conflict resolution against current main --- packages/cli/src/utils/publishProject.test.ts | 130 ++++++------- packages/cli/src/utils/publishProject.ts | 177 +++++++++++++----- 2 files changed, 190 insertions(+), 117 deletions(-) diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index 83b2b998332..f9ed4172787 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, renameSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join, posix, relative } from "node:path"; +import { join, relative } from "node:path"; import AdmZip from "adm-zip"; const authMocks = vi.hoisted(() => ({ @@ -38,6 +38,8 @@ import { getPublishApiBaseUrl, localizeExternalAssets, publishProjectArchive, + readStaged, + stagePublishFiles, uploadTimeoutMs, zipPublishFileMap, } from "./publishProject.js"; @@ -214,69 +216,6 @@ async function runAuthenticatedPublish( } describe("createPublishArchive", () => { - it.each(["index.html", "compositions/scene.html"])( - "includes external video posters referenced from %s", - (entryPath) => { - const projectDir = makeProjectDir(); - const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); - try { - const htmlDir = join(projectDir, dirname(entryPath)); - mkdirSync(htmlDir, { recursive: true }); - if (entryPath !== "index.html") { - writeFileSync(join(projectDir, "index.html"), ""); - } - const posterPath = stageExternalAsset(extDir, htmlDir, "poster.png", "PNG_DATA"); - const videoPath = posix.relative(posix.dirname(entryPath), "clip.mp4"); - const html = ``; - writeFileSync(join(projectDir, entryPath), html); - writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA"); - - const archive = createPublishArchive(projectDir); - const zip = new AdmZip(archive.buffer); - const posterEntries = zip - .getEntries() - .filter((entry) => entry.entryName.startsWith("_ext/") && entry.name === "poster.png"); - - expect(posterEntries).toHaveLength(1); - const archivedPoster = posterEntries[0]!; - expect(archivedPoster.getData()).toEqual(Buffer.from("PNG_DATA")); - expect(zip.readAsText(entryPath)).toContain( - `poster="${posix.relative(posix.dirname(entryPath), archivedPoster.entryName)}"`, - ); - expect(zip.readAsText(entryPath)).toContain(`src="${videoPath}"`); - expect(archive.fileCount).toBe(entryPath === "index.html" ? 3 : 4); - expect(readFileSync(join(projectDir, entryPath), "utf-8")).toBe(html); - } finally { - rmSync(projectDir, { recursive: true, force: true }); - rmSync(extDir, { recursive: true, force: true }); - } - }, - ); - - it("preserves internal, remote, and inline video posters", () => { - const projectDir = makeProjectDir(); - try { - const posters = [ - "poster.png", - "https://cdn.example.com/poster.png", - "data:image/png;base64,AA==", - ]; - const html = `${posters.map((poster) => ``).join("")}`; - writeFileSync(join(projectDir, "index.html"), html); - writeFileSync(join(projectDir, "poster.png"), "PNG_DATA"); - writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA"); - - const archive = createPublishArchive(projectDir); - const zip = new AdmZip(archive.buffer); - - expect(archive.fileCount).toBe(3); - expect(zip.readAsText("index.html")).toBe(html); - expect(zip.getEntries().some((entry) => entry.entryName.startsWith("_ext/"))).toBe(false); - } finally { - rmSync(projectDir, { recursive: true, force: true }); - } - }); - it("packages the project and skips hidden files and node_modules", () => { const dir = makeProjectDir(); try { @@ -749,6 +688,30 @@ describe("publishProjectArchive", () => { }); }); +describe("publishProjectArchive", () => { + it("keeps server-sent upload headers in any case without adding duplicates", async () => { + const dir = makeProjectDir(); + const serverHeaders = { + "Content-Type": "application/zip", + "Content-Length": "123", + "X-Amz-Server-Side-Encryption": "AES256", + }; + const fetchMock = stagedFetch( + { file_count: 1 }, + { upload_url: signedStagedS3Url, upload_headers: serverHeaders }, + ); + vi.stubGlobal("fetch", fetchMock); + + try { + writeFileSync(join(dir, "index.html"), "", "utf-8"); + await publishProjectArchive(dir); + expect(fetchMock.mock.calls[1]![1].headers).toEqual(serverHeaders); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("publishProjectArchive", () => { it("authenticates staged metadata requests but not the presigned S3 upload", async () => { const dir = makeProjectDir(); @@ -1187,6 +1150,22 @@ describe("publishProjectArchive with a credential the server rejects", () => { await publishExpecting(fetchMock, EXPIRED); }); + it("asks a signed-out publish to log in when the upload route answers 401, with no fallback", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(unauthorized()); + vi.stubGlobal("fetch", fetchMock); + const dir = makeProjectDir(); + try { + writeFileSync(join(dir, "index.html"), "", "utf-8"); + await expect(publishProjectArchive(dir)).rejects.toThrow( + "Publishing needs an account. Run hyperframes auth login to publish.", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![0]).toMatch(/\/publish\/upload$/); + }); + it("does not blame the login for a 401 from the storage upload, which carries no credential", async () => { const fetchMock = vi .fn() @@ -1376,3 +1355,24 @@ describe("publishProjectArchive with an expired login and no refresh token", () } }); }); + +describe("readStaged", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "hf-staged-")); + writeFileSync(join(dir, "index.html"), ""); + writeFileSync(join(dir, "clip.bin"), "staged bytes"); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it("reads a file left on disk only while it is the file that was staged", () => { + const staged = stagePublishFiles(dir, 4).get("clip.bin")!; + expect(Buffer.isBuffer(staged)).toBe(false); + expect(readStaged(staged).toString()).toBe("staged bytes"); + + writeFileSync(join(dir, "swap.bin"), "other bytes"); + utimesSync(join(dir, "swap.bin"), 1, 1); + renameSync(join(dir, "swap.bin"), join(dir, "clip.bin")); + expect(() => readStaged(staged)).toThrow(/changed on disk/); + }); +}); diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index c3c615c200a..ac39bcfb53c 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -1,5 +1,14 @@ import { basename, dirname, join, posix, relative, resolve } from "node:path"; -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { + closeSync, + existsSync, + fstatSync, + openSync, + readdirSync, + readFileSync, + type Stats, + statSync, +} from "node:fs"; import { parseHTML } from "linkedom"; import AdmZip from "adm-zip"; import ignore, { type Ignore } from "ignore"; @@ -130,27 +139,22 @@ function getUploadHeaders( } } - if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) { - headers["content-type"] = contentType; + setHeaderIfAbsent(headers, "content-type", contentType); + const signedHeaders = + new URL(uploadUrl).searchParams.get("X-Amz-SignedHeaders")?.split(";") ?? []; + if (signedHeaders.includes("x-amz-server-side-encryption")) { + setHeaderIfAbsent(headers, "x-amz-server-side-encryption", "AES256"); } - - const signedHeaders = new URL(uploadUrl).searchParams.get("X-Amz-SignedHeaders"); - if ( - signedHeaders?.split(";").includes("x-amz-server-side-encryption") && - !Object.keys(headers).some((key) => key.toLowerCase() === "x-amz-server-side-encryption") - ) { - headers["x-amz-server-side-encryption"] = "AES256"; - } - if ( - signedHeaders?.split(";").includes("content-length") && - !Object.keys(headers).some((key) => key.toLowerCase() === "content-length") - ) { - headers["content-length"] = String(archiveByteLength); + if (signedHeaders.includes("content-length")) { + setHeaderIfAbsent(headers, "content-length", String(archiveByteLength)); } - return headers; } +function setHeaderIfAbsent(headers: Record, name: string, value: string): void { + if (!Object.keys(headers).some((key) => key.toLowerCase() === name)) headers[name] = value; +} + async function readJson(response: Response): Promise { return response .clone() @@ -166,6 +170,7 @@ async function metadataRequestError(response: Response, fallback: string): Promi } const LOGIN_EXPIRED = "Your login expired. Run hyperframes auth login, then publish again."; +const LOGIN_REQUIRED = "Publishing needs an account. Run hyperframes auth login to publish."; const LOGIN_CHANGED = "Your login changed during publish. Run publish again."; function rejectedCredentialMessage(credential: ResolvedCredential): string { const envVar = envCredentialVar(credential.source); @@ -337,9 +342,66 @@ function collectProjectFiles( const EXT_ASSETS_PREFIX = "_ext"; +/** Files whose text a reference rewrite or scan reads; they are always staged in memory. */ +export const TEXT_FILE_EXTS = /\.(html?|css|m?js|cjs|json|svg|xml|txt)$/i; + +/** A large non-text project file left on disk; `size` and `mtimeMs` detect an edit after staging. */ +export interface DiskFile { + absPath: string; + size: number; + mtimeMs: number; +} + +export type StagedFile = Buffer | DiskFile; + +export function stagedSize(file: StagedFile): number { + return Buffer.isBuffer(file) ? file.byteLength : file.size; +} + +function assertSame(file: DiskFile, now: Stats | undefined): void { + if (now?.size !== file.size || now.mtimeMs !== file.mtimeMs) { + throw new Error(`${file.absPath} changed on disk while it was being prepared. Run it again.`); + } +} + +export function assertUnchanged(file: DiskFile): void { + assertSame(file, statSync(file.absPath, { throwIfNoEntry: false })); +} + +/** Reads through one descriptor, so `check` (run before and after) sees the stat of the bytes returned. */ +export function readChecked(path: string, check: (stat: Stats) => void): Buffer { + const fd = openSync(path, "r"); + try { + check(fstatSync(fd)); + const bytes = readFileSync(fd); + check(fstatSync(fd)); + return bytes; + } finally { + closeSync(fd); + } +} + +/** The staged bytes; a file left on disk is read now and must not have changed since staging. */ +export function readStaged(file: StagedFile): Buffer { + if (Buffer.isBuffer(file)) return file; + return readChecked(file.absPath, (stat) => assertSame(file, stat)); +} + +function stageFile(absPath: string, onDiskMinBytes: number): StagedFile { + const fd = openSync(absPath, "r"); + try { + const { size, mtimeMs } = fstatSync(fd); + if (size < onDiskMinBytes || TEXT_FILE_EXTS.test(absPath)) return readFileSync(fd); + return { absPath, size, mtimeMs }; + } finally { + closeSync(fd); + } +} + interface ExternalAssetContext { absProjectDir: string; - fileContents: Map; + fileContents: Map; + onDiskMinBytes: number; externalMap: Map; usedArchivePaths: Set; } @@ -360,7 +422,7 @@ function addExternalAsset(ctx: ExternalAssetContext, absPath: string): string { archivePath = `${base}_${i}${ext}`; } - ctx.fileContents.set(archivePath, readFileSync(absPath)); + ctx.fileContents.set(archivePath, stageFile(absPath, ctx.onDiskMinBytes)); ctx.externalMap.set(absPath, archivePath); ctx.usedArchivePaths.add(archivePath); return archivePath; @@ -382,26 +444,30 @@ function tryResolveExternal( return addExternalAsset(ctx, absPath); } -function rewriteCssUrls( - ctx: ExternalAssetContext, +/** Resolves a raw attribute or CSS `url()` value to the archive path or absolute + * URL it should point at, or `null` to leave it untouched. */ +export type HtmlAttributeResolver = (rawValue: string, referrerAbsDir: string) => string | null; + +function referenceFrom(entryPath: string, target: string): string { + return isNonRelativeUrl(target) ? target : posix.relative(posix.dirname(entryPath), target); +} + +export function rewriteCssUrls( css: string, referrerAbsDir: string, entryPath: string, + resolveTarget: HtmlAttributeResolver, ): { css: string; modified: boolean } { let modified = false; const rewritten = css.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => { - const archivePath = tryResolveExternal(ctx, (rawUrl || "").trim(), referrerAbsDir); - if (!archivePath) return full; + const target = resolveTarget((rawUrl || "").trim(), referrerAbsDir); + if (!target) return full; modified = true; - return `url(${quote || ""}${posix.relative(posix.dirname(entryPath), archivePath)}${quote || ""})`; + return `url(${quote || ""}${referenceFrom(entryPath, target)}${quote || ""})`; }); return { css: rewritten, modified }; } -/** Resolves a raw attribute value (plus the referrer's absolute directory) to - * the archive path it should point at, or `null` to leave it untouched. */ -export type HtmlAttributeResolver = (rawValue: string, referrerAbsDir: string) => string | null; - interface RewriteHtmlAttributesOptions { /** Attributes to inspect (default: src + href, matching the external-asset * localization use case below). */ @@ -435,26 +501,26 @@ export function rewriteHtmlAttributes( for (const attr of attrs) { const val = (el.getAttribute(attr) || "").trim(); if (!val) continue; - const archivePath = resolveTarget(val, referrerAbsDir); - if (!archivePath) continue; - el.setAttribute(attr, posix.relative(posix.dirname(entryPath), archivePath)); + const target = resolveTarget(val, referrerAbsDir); + if (!target) continue; + el.setAttribute(attr, referenceFrom(entryPath, target)); modified = true; } } return modified; } -function rewriteStyleBlocks( - ctx: ExternalAssetContext, +export function rewriteStyleBlocks( document: Document, referrerAbsDir: string, entryPath: string, + resolveTarget: HtmlAttributeResolver, ): boolean { let modified = false; for (const styleEl of document.querySelectorAll("style")) { const css = styleEl.textContent || ""; if (!css.includes("url(")) continue; - const result = rewriteCssUrls(ctx, css, referrerAbsDir, entryPath); + const result = rewriteCssUrls(css, referrerAbsDir, entryPath, resolveTarget); if (result.modified) { styleEl.textContent = result.css; modified = true; @@ -463,7 +529,7 @@ function rewriteStyleBlocks( for (const el of document.querySelectorAll("[style]")) { const style = el.getAttribute("style") || ""; if (!style.includes("url(")) continue; - const result = rewriteCssUrls(ctx, style, referrerAbsDir, entryPath); + const result = rewriteCssUrls(style, referrerAbsDir, entryPath, resolveTarget); if (result.modified) { el.setAttribute("style", result.css); modified = true; @@ -475,14 +541,9 @@ function rewriteStyleBlocks( function localizeHtmlEntry(ctx: ExternalAssetContext, entryPath: string, content: Buffer): void { const referrerAbsDir = resolve(ctx.absProjectDir, dirname(entryPath)); const { document } = parseHTML(content.toString("utf-8")); - const attrsChanged = rewriteHtmlAttributes( - document, - referrerAbsDir, - entryPath, - (val, dir) => tryResolveExternal(ctx, val, dir), - { attrs: ["src", "href", "poster"] }, - ); - const stylesChanged = rewriteStyleBlocks(ctx, document, referrerAbsDir, entryPath); + const resolveTarget: HtmlAttributeResolver = (val, dir) => tryResolveExternal(ctx, val, dir); + const attrsChanged = rewriteHtmlAttributes(document, referrerAbsDir, entryPath, resolveTarget); + const stylesChanged = rewriteStyleBlocks(document, referrerAbsDir, entryPath, resolveTarget); if (attrsChanged || stylesChanged) { ctx.fileContents.set(entryPath, Buffer.from(document.toString(), "utf-8")); } @@ -492,7 +553,9 @@ function localizeCssEntry(ctx: ExternalAssetContext, entryPath: string, content: const referrerAbsDir = resolve(ctx.absProjectDir, dirname(entryPath)); const css = content.toString("utf-8"); if (!css.includes("url(")) return; - const result = rewriteCssUrls(ctx, css, referrerAbsDir, entryPath); + const result = rewriteCssUrls(css, referrerAbsDir, entryPath, (val, dir) => + tryResolveExternal(ctx, val, dir), + ); if (result.modified) { ctx.fileContents.set(entryPath, Buffer.from(result.css, "utf-8")); } @@ -505,17 +568,19 @@ function localizeCssEntry(ctx: ExternalAssetContext, entryPath: string, content: */ export function localizeExternalAssets( absProjectDir: string, - fileContents: Map, + fileContents: Map, + onDiskMinBytes = Infinity, ): number { const ctx: ExternalAssetContext = { absProjectDir, fileContents, + onDiskMinBytes, externalMap: new Map(), usedArchivePaths: new Set(), }; for (const [entryPath, content] of [...fileContents.entries()]) { - if (entryPath.startsWith(EXT_ASSETS_PREFIX + "/")) continue; + if (entryPath.startsWith(EXT_ASSETS_PREFIX + "/") || !Buffer.isBuffer(content)) continue; if (entryPath.endsWith(".html") || entryPath.endsWith(".htm")) { localizeHtmlEntry(ctx, entryPath, content); } else if (entryPath.endsWith(".css")) { @@ -534,6 +599,14 @@ export function localizeExternalAssets( * keeps calling `createPublishArchive` directly. */ export function buildPublishFileMap(projectDir: string): Map { + return stagePublishFiles(projectDir, Infinity) as Map; +} + +/** {@link buildPublishFileMap}, but non-text files of `onDiskMinBytes` or more stay on disk. */ +export function stagePublishFiles( + projectDir: string, + onDiskMinBytes: number, +): Map { const absProjectDir = resolve(projectDir); const filePaths: string[] = []; collectProjectFiles(absProjectDir, absProjectDir, filePaths, createProjectIgnore(absProjectDir)); @@ -543,12 +616,12 @@ export function buildPublishFileMap(projectDir: string): Map { ); } - const fileContents = new Map(); + const fileContents = new Map(); for (const filePath of filePaths) { - fileContents.set(filePath, readFileSync(join(absProjectDir, filePath))); + fileContents.set(filePath, stageFile(join(absProjectDir, filePath), onDiskMinBytes)); } - localizeExternalAssets(absProjectDir, fileContents); + localizeExternalAssets(absProjectDir, fileContents, onDiskMinBytes); return fileContents; } @@ -564,7 +637,7 @@ export function buildPublishFileMap(projectDir: string): Map { * rather than of its contents, which is the opposite of what a * content-addressed artifact needs. * - * Built from local components on purpose: `fromDate2DOS` reads `getFullYear`, + * Built from local components on purpose: adm-zip's DOS date encoding reads `getFullYear`, * `getMonth`, `getHours` and friends, so a fixed *instant* would still encode * differently in different timezones. Fixing the wall-clock reading is what * makes the bytes match across machines. 1980-01-01 is the earliest a DOS @@ -801,8 +874,8 @@ export async function publishProjectArchive( throw new CredentialRejectedError("unclaimed publish"); } } catch (error) { - if (error instanceof CredentialRejectedError && credential) { - throw new Error(rejectedCredentialMessage(credential)); + if (error instanceof CredentialRejectedError) { + throw new Error(credential ? rejectedCredentialMessage(credential) : LOGIN_REQUIRED); } throw error; } From a14c5d944456d7a0d0e53395c91cf1be3bdd1583 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 13:40:18 +0800 Subject: [PATCH 3/3] fix(cli): retain poster localization with shared archive resolvers --- docs/guides/export-and-share.mdx | 4 + packages/cli/src/utils/publishProject.test.ts | 75 ++++++++++++++++++- packages/cli/src/utils/publishProject.ts | 4 +- 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/guides/export-and-share.mdx b/docs/guides/export-and-share.mdx index c6426e42705..c81875b373b 100644 --- a/docs/guides/export-and-share.mdx +++ b/docs/guides/export-and-share.mdx @@ -128,6 +128,10 @@ The command uploads the project and returns a stable URL. An anonymous first publish creates a claimable link. Sign in when the CLI should own the project immediately, update an existing link, or publish into a shared space. +A local video poster referenced with a relative path, such as +`../shared/poster.png`, is copied into the published project even when it lives +outside the project directory. + Before sharing source, remove secrets and confirm that every included media file can be shared. Tell a reviewer whether you need feedback on story, visuals, timing, facts, or final approval. diff --git a/packages/cli/src/utils/publishProject.test.ts b/packages/cli/src/utils/publishProject.test.ts index f9ed4172787..22b0290edfe 100644 --- a/packages/cli/src/utils/publishProject.test.ts +++ b/packages/cli/src/utils/publishProject.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, renameSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; +import { dirname, join, posix, relative } from "node:path"; import AdmZip from "adm-zip"; const authMocks = vi.hoisted(() => ({ @@ -216,6 +224,69 @@ async function runAuthenticatedPublish( } describe("createPublishArchive", () => { + it.each(["index.html", "compositions/scene.html"])( + "includes external video posters referenced from %s", + (entryPath) => { + const projectDir = makeProjectDir(); + const extDir = mkdtempSync(join(tmpdir(), "hf-ext-")); + try { + const htmlDir = join(projectDir, dirname(entryPath)); + mkdirSync(htmlDir, { recursive: true }); + if (entryPath !== "index.html") { + writeFileSync(join(projectDir, "index.html"), ""); + } + const posterPath = stageExternalAsset(extDir, htmlDir, "poster.png", "PNG_DATA"); + const videoPath = posix.relative(posix.dirname(entryPath), "clip.mp4"); + const html = ``; + writeFileSync(join(projectDir, entryPath), html); + writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA"); + + const archive = createPublishArchive(projectDir); + const zip = new AdmZip(archive.buffer); + const posterEntries = zip + .getEntries() + .filter((entry) => entry.entryName.startsWith("_ext/") && entry.name === "poster.png"); + + expect(posterEntries).toHaveLength(1); + const archivedPoster = posterEntries[0]!; + expect(archivedPoster.getData()).toEqual(Buffer.from("PNG_DATA")); + expect(zip.readAsText(entryPath)).toContain( + `poster="${posix.relative(posix.dirname(entryPath), archivedPoster.entryName)}"`, + ); + expect(zip.readAsText(entryPath)).toContain(`src="${videoPath}"`); + expect(archive.fileCount).toBe(entryPath === "index.html" ? 3 : 4); + expect(readFileSync(join(projectDir, entryPath), "utf-8")).toBe(html); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + rmSync(extDir, { recursive: true, force: true }); + } + }, + ); + + it("preserves internal, remote, and inline video posters", () => { + const projectDir = makeProjectDir(); + try { + const posters = [ + "poster.png", + "https://cdn.example.com/poster.png", + "data:image/png;base64,AA==", + ]; + const html = `${posters.map((poster) => ``).join("")}`; + writeFileSync(join(projectDir, "index.html"), html); + writeFileSync(join(projectDir, "poster.png"), "PNG_DATA"); + writeFileSync(join(projectDir, "clip.mp4"), "MP4_DATA"); + + const archive = createPublishArchive(projectDir); + const zip = new AdmZip(archive.buffer); + + expect(archive.fileCount).toBe(3); + expect(zip.readAsText("index.html")).toBe(html); + expect(zip.getEntries().some((entry) => entry.entryName.startsWith("_ext/"))).toBe(false); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + it("packages the project and skips hidden files and node_modules", () => { const dir = makeProjectDir(); try { diff --git a/packages/cli/src/utils/publishProject.ts b/packages/cli/src/utils/publishProject.ts index ac39bcfb53c..9c05f5c809c 100644 --- a/packages/cli/src/utils/publishProject.ts +++ b/packages/cli/src/utils/publishProject.ts @@ -542,7 +542,9 @@ function localizeHtmlEntry(ctx: ExternalAssetContext, entryPath: string, content const referrerAbsDir = resolve(ctx.absProjectDir, dirname(entryPath)); const { document } = parseHTML(content.toString("utf-8")); const resolveTarget: HtmlAttributeResolver = (val, dir) => tryResolveExternal(ctx, val, dir); - const attrsChanged = rewriteHtmlAttributes(document, referrerAbsDir, entryPath, resolveTarget); + const attrsChanged = rewriteHtmlAttributes(document, referrerAbsDir, entryPath, resolveTarget, { + attrs: ["src", "href", "poster"], + }); const stylesChanged = rewriteStyleBlocks(document, referrerAbsDir, entryPath, resolveTarget); if (attrsChanged || stylesChanged) { ctx.fileContents.set(entryPath, Buffer.from(document.toString(), "utf-8"));