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

  1. Create the missing assets/ directory next to current.json and place the manifest's asset file at its declared path
  2. Replace the assets symlink with a real directory containing the asset files
  3. Verify you pointed the script's source directory argument at the correct announcement bundle root
  4. 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

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


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)