paperclipai/paperclip · error · Error
Assets must be a real directory
Error message
Assets must be a real directory
What it means
prepareAnnouncementPublish validates the announcement bundle before uploading it to the page host. When the manifest declares an image or animation asset, it requires the bundle's assets/ entry to be a real directory (checked with lstat, not stat, so symlinks fail). This error means the assets path is missing, is a file, or is a symlink rather than a genuine directory.
Solutions
- Create the missing assets/ directory next to current.json and place the manifest's asset file at its declared path
- Replace the assets symlink with a real directory containing the asset files
- Verify you pointed the script's source directory argument at the correct announcement bundle root
- Re-export or re-package the announcement bundle so assets are materialized on disk
Example fix
// before (assets is a symlink or missing) ls announcement/ # current.json only // after mkdir -p announcement/assets cp image.png announcement/assets/<sha256>.png
Defensive patterns
Strategy: validation
Validate before calling
import { lstat } from "node:fs/promises";
const s = await lstat(path.join(source, "assets"));
if (!s.isDirectory()) throw new Error("assets must be a real directory, not a symlink/file"); Type guard
const isRealDir = async (p: string) => { try { return (await lstat(p)).isDirectory(); } catch { return false; } }; Try / catch
try { await prepareAnnouncementPublish(src, staging, prefix); } catch (e) { if (e.message === "Assets must be a real directory") { /* repair bundle: mkdir assets, remove symlink */ } } Prevention
- Always package announcement bundles with a real assets/ directory, not symlinks
- Run a pre-publish validation of the bundle layout (manifest + assets present)
- Verify the source directory argument points at the bundle root before running
- Never copy only current.json when moving announcements between environments
When it happens
Trigger: The manifest contains announcement.image or announcement.animation, but `<source>/assets` does not exist, is a regular file, or is a symlink to a directory. lstat().isDirectory() returns false in all those cases.
Common situations: Exporting an announcement directory without its assets folder; packaging the bundle by copying only current.json; using a symlinked assets directory from a shared cache; running the publish script from the wrong source directory.
Related errors
- Attempt source is not a real directory
- codex auth cache: account_id is not a valid account handle
- Codex working directory cannot be a filesystem root
- Codex working directory must exist before provider admission
- Incomplete trusted viewer build
AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18).
Data as JSON: /api/errors/ea9c27e58fb043ed.
Report an issue: GitHub.
Appendix: source
Thrown at scripts/publish-announcements.ts:53
sourceDirectory = arg;
}
}
return { sourceDirectory: sourceDirectory ?? (staging ? "announcements/examples/staging" : "announcements"), staging, publish: mode === "publish" };
}
export async function prepareAnnouncementPublish(sourceDirectory: string, staging?: string, hostPrefix?: string) {
const prefix = announcementPublishPrefix(staging, hostPrefix);
const source = path.resolve(sourceDirectory);
if (!(await lstat(source)).isDirectory()) throw new Error("Source must be a real directory");
const manifestPath = path.join(source, "current.json");
const stat = await lstat(manifestPath);
if (!stat.isFile() || stat.size > ANNOUNCEMENT_MANIFEST_MAX_BYTES) throw new Error("Invalid or oversized current.json");
const manifest = announcementManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8")));
const files: Array<{ file: string; key: string; contentType: string; cacheControl: string }> = [];
for (const kind of ["image", "animation"] as const) {
const asset = manifest.announcement?.[kind];
if (!asset) continue;
if (!(await lstat(path.join(source, "assets"))).isDirectory()) throw new Error("Assets must be a real directory");
const assetPath = asset.path;
const file = path.join(source, assetPath);
const assetStat = await lstat(file);
const maximum = kind === "animation" ? ANNOUNCEMENT_ANIMATION_MAX_BYTES : ANNOUNCEMENT_IMAGE_MAX_BYTES;
if (!assetStat.isFile() || assetStat.size > maximum) throw new Error(`Invalid or oversized ${kind}`);
const bytes = await readFile(file);
const digest = createHash("sha256").update(bytes).digest("hex");
if (!assetPath.startsWith(`assets/${digest}.`)) throw new Error("Asset filename must match its SHA-256 digest");
if (kind === "animation") validateAnnouncementAnimation(bytes);
files.push({ file, key: `${prefix}/${assetPath}`, contentType: kind === "animation" ? "text/html" : assetPath.endsWith(".png") ? "image/png" : assetPath.endsWith(".jpg") ? "image/jpeg" : "image/webp", cacheControl: "public,max-age=31536000,immutable" });
}
files.push({ file: manifestPath, key: `${prefix}/current.json`, contentType: "application/json", cacheControl: "public,max-age=300" });
return { manifest, files };
}
export function announcementUploadArgs(bucket: string, file: Awaited<ReturnType<typeof prepareAnnouncementPublish>>["files"][number]) {
return ["s3api", "put-object", "--bucket", bucket, "--key", file.key, "--body", file.file,
"--content-type", file.contentType, "--cache-control", file.cacheControl];View on GitHub (pinned to 3f1d897a7c)