jackwener/OpenCLI · error · ArgumentError

output path is not a safe directory: ${ancestor}

Error message

output path is not a safe directory: ${ancestor}

What it means

While walking up the path to find the deepest existing ancestor, the function calls fs.lstatSync on each ancestor. If lstat fails with anything other than ENOENT (e.g. EACCES, ELOOP, ENOTDIR, EPERM), the path cannot be proven safe, so an ArgumentError naming the failing ancestor is thrown. ENOENT is tolerated because missing segments are created later.

Source

Thrown at clis/pixiv/novel-download-utils.js:75

export function normalizePixivOutputRoot(value, fallback) {
  if (value !== undefined && typeof value !== 'string') {
    throw new ArgumentError('output must be a directory path');
  }
  const raw = value ?? fallback;
  if (!raw || raw.includes('\0')) {
    throw new ArgumentError('output must be a non-empty directory path');
  }
  const resolved = path.resolve(raw);
  let ancestor = resolved;
  const missingParts = [];
  let ancestorStat;
  while (!ancestorStat) {
    try {
      ancestorStat = fs.lstatSync(ancestor);
    } catch (error) {
      if (error?.code !== 'ENOENT') {
        throw new ArgumentError(`output path is not a safe directory: ${ancestor}`);
      }
      const parent = path.dirname(ancestor);
      if (parent === ancestor) {
        throw new ArgumentError(`output path is not a safe directory: ${resolved}`);
      }
      missingParts.unshift(path.basename(ancestor));
      ancestor = parent;
    }
  }
  if (ancestor === resolved && ancestorStat.isSymbolicLink()) {
    throw new ArgumentError(`output path must not be a symbolic link: ${resolved}`);
  }
  let canonicalAncestor;
  try {
    canonicalAncestor = fs.realpathSync.native(ancestor);
  } catch {
    throw new ArgumentError(`output path is not a safe directory: ${ancestor}`);
  }

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Check and fix permissions on the failing ancestor directory (chmod/chown so the running user can traverse it).
  2. Remove or rename any regular file that occupies a component of the output path.
  3. Check for symlink loops (ls -l / find -type l) and break the cycle.
  4. Choose a different output directory inside a writable, traversable location such as your project or home directory.

Example fix

// before (shell)
$ pixiv-novel download 12345 -o /root/novels   # EACCES
// after (shell)
$ pixiv-novel download 12345 -o ~/novels
Defensive patterns

Strategy: validation

Validate before calling

import fs from 'node:fs';
function pathIsInspectable(p) {
  try { fs.accessSync(p, fs.constants.X_OK); return true; }
  catch (e) { console.error(`Cannot traverse ${p}: ${e.code}`); return false; }
}

Try / catch

try {
  await downloadNovel(id, { output });
} catch (e) {
  if (e.name === 'ArgumentError' && e.message.includes('not a safe directory')) {
    console.error(`Fix permissions/type on path component: ${e.message}`);
  } else throw e;
}

Prevention

When it happens

Trigger: The output path (or an ancestor) is unreadable: a permission-denied directory (EACCES/EPERM), a symlink loop (ELOOP), a non-directory component in the middle of the path (ENOTDIR), or an I/O error on the volume.

Common situations: Running the CLI as a user without execute permission on a parent directory (e.g. /root/out); output path like ./file.txt/subdir where file.txt is a regular file; mounted network volume that went offline.

Related errors


AI-assisted analysis of jackwener/OpenCLI@49907e53dc (2026-08-29). Data as JSON: /api/errors/acf881e818d94ad2. Report an issue: GitHub.