paperclipai/paperclip · error · Error

Source must be a real directory

Error message

Source must be a real directory

What it means

prepareAnnouncementPublish resolves the given source directory and requires an on-disk directory; lstat is called and the result checked with isDirectory(). A nonexistent path (lstat throws ENOENT) or a path that is a file/symlink-to-file fails this check and throws 'Source must be a real directory'.

Solutions

  1. Verify the directory exists: `ls <dir>` from the same working directory the script runs in.
  2. Pass an absolute path or run the CLI from the repository root where 'announcements' lives.
  3. Point the argument at the directory containing current.json, not at current.json itself.
  4. Fix broken symlinks or case mismatches in the path.

Example fix

// before
publish-announcements.ts ./announcments --publish
// after
publish-announcements.ts ./announcements --publish
Defensive patterns

Strategy: validation

Validate before calling

import { statSync } from 'node:fs';
const source = path.resolve(dir);
if (!statSync(source, { throwIfNoEntry: false })?.isDirectory()) {
  throw new Error(`Source directory not found: ${source} (cwd=${process.cwd()})`);
}

Type guard

const isDirectory = async (p) => { try { return (await lstat(p)).isDirectory(); } catch { return false; } };

Try / catch

try {
  await prepareAnnouncementPublish(dir, staging, prefix);
} catch (e) {
  if (String(e.message) === 'Source must be a real directory' || e.code === 'ENOENT') {
    console.error(`Directory ${dir} does not exist; cwd=${process.cwd()}`);
    process.exit(1);
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling prepareAnnouncementPublish with a path that does not exist, points to a regular file (e.g. current.json itself), is a broken symlink, or is relative to the wrong working directory (common in CI where cwd differs).

Common situations: Running the publish CLI from a different working directory so the relative path 'announcements' does not exist; renaming the announcements folder; passing a file path instead of the directory; case-sensitive filesystem mismatch ('Announcements' vs 'announcements').

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/1558184a113c9c01. Report an issue: GitHub.

Appendix: source

Thrown at scripts/publish-announcements.ts:44

    if (arg === "--publish" || arg === "--dry-run") {
      if (mode) throw new Error(usage);
      mode = arg === "--publish" ? "publish" : "dry-run";
    } else if (arg === "--staging") {
      if (staging !== undefined || !args[index + 1]) throw new Error(usage);
      staging = announcementIdSchema.parse(args[++index]);
    } else if (arg.startsWith("--") || sourceDirectory !== undefined) {
      throw new Error(usage);
    } else {
      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);

View on GitHub (pinned to 3f1d897a7c)