jackwener/OpenCLI · error

Expected monorepo plugin link at ${linkPath} to be a symlink

Error message

Expected monorepo plugin link at ${linkPath} to be a symlink

What it means

beginReplaceSymlink atomically replaces the symlink in the plugins dir that points at a monorepo sub-plugin. It throws a plain Error if the path exists but is NOT a symlink (a real file or directory sits where the link should be), because the transactional swap would destroy real data.

Source

Thrown at src/plugin.ts:382

        try { fsOps.rmSync(backupDest, { recursive: true, force: true }); } catch {}
      }
    },
    rollback() {
      if (settled) return;
      settled = true;
      try { fsOps.rmSync(dest, { recursive: true, force: true }); } catch {}
      if (backupDest) {
        try { fsOps.renameSync(backupDest, dest); } catch {}
      }
      try { fsOps.rmSync(tempDest, { recursive: true, force: true }); } catch {}
    },
  };
}

function beginReplaceSymlink(target: string, linkPath: string): TransactionHandle {
  const linkExists = pathExistsSync(linkPath);
  if (linkExists && !isSymlinkSync(linkPath)) {
    throw new Error(`Expected monorepo plugin link at ${linkPath} to be a symlink`);
  }

  fs.mkdirSync(path.dirname(linkPath), { recursive: true });

  const tempLink = createSiblingTempPath(linkPath, 'tmp');
  const backupLink = linkExists ? createSiblingTempPath(linkPath, 'bak') : null;
  const linkType = isWindows ? 'junction' : 'dir';
  let settled = false;

  try {
    fs.symlinkSync(target, tempLink, linkType);
    if (backupLink) {
      fs.renameSync(linkPath, backupLink);
    }
    fs.renameSync(tempLink, linkPath);
  } catch (err) {
    removePathSync(tempLink);
    if (backupLink && !pathExistsSync(linkPath)) {

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Uninstall the conflicting plugin: `opencli plugin uninstall <name>` (this removes the directory).
  2. If it is a leftover directory, remove it manually: `rm -rf ~/.opencli/plugins/<name>`.
  3. Rename the monorepo sub-plugin in its manifest to avoid the collision.
  4. Re-run install/update afterward.

Example fix

// before
opencli plugin install github:user/monorepo/shared-name // fails: real dir exists
// after
opencli plugin uninstall shared-name
opencli plugin install github:user/monorepo/shared-name
Defensive patterns

Strategy: validation

Validate before calling

import * as fs from 'node:fs';
const linkPath = `${pluginsDir}/${name}`;
if (fs.existsSync(linkPath) && !fs.lstatSync(linkPath).isSymbolicLink()) {
  fs.rmSync(linkPath, { recursive: true }); // or uninstall the plugin first
}

Type guard

function isSymlink(p) {
  try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; }
}

Try / catch

try {
  installPlugin('github:user/monorepo');
} catch (err) {
  if (err.message.startsWith('Expected monorepo plugin link')) {
    uninstallPlugin(conflictingName); // then retry install
  } else throw err;
}

Prevention

When it happens

Trigger: publishMonorepoPlugins (via installMonorepo or updatePlugin of monorepo sub-plugins) when ~/.opencli/plugins/<name> already exists as a regular directory or file — typically a previously-installed standalone plugin with the same name, or a leftover from a crashed install.

Common situations: Name collision between a standalone plugin and a monorepo sub-plugin; user copied a real directory into the plugins folder manually; a previous failed install left a materialized directory where a symlink belongs.

Related errors


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