Skip to content

fix: use full URL for file:// plugin deduplication#8758

Closed
coleleavitt wants to merge 6 commits intoanomalyco:devfrom
coleleavitt:fix/file-plugin-deduplication
Closed

fix: use full URL for file:// plugin deduplication#8758
coleleavitt wants to merge 6 commits intoanomalyco:devfrom
coleleavitt:fix/file-plugin-deduplication

Conversation

@coleleavitt
Copy link
Contributor

@coleleavitt coleleavitt commented Jan 15, 2026

Summary

Fixes a bug in plugin deduplication logic where multiple file:// plugins with the same filename (e.g., index.js) would be incorrectly treated as duplicates and only the last one would load.

Problem

The getPluginName() function extracted only the filename for file:// URLs:

// Before
if (plugin.startsWith("file://")) {
  return path.parse(new URL(plugin).pathname).name  // "index"
}

This caused issues when using multiple plugins with standard entry points:

{
  "plugin": [
    "file:///path/to/plugin-a/dist/index.js",  // -> "index"
    "file:///path/to/plugin-b/dist/index.js",  // -> "index" (duplicate!)
    "file:///path/to/plugin-c/dist/index.js"   // -> "index" (duplicate!)
  ]
}

Result: Only the last plugin loaded because all were seen as duplicates of "index".

Solution

Use the full URL as the identity for file:// plugins:

// After
if (plugin.startsWith("file://")) {
  return plugin  // Full URL
}

This allows:

  • ✅ Multiple plugins in the same directory with different filenames
  • ✅ Multiple plugins in different directories with the same filename
  • ✅ Same file:// URL appearing twice → correctly deduplicated
  • ✅ npm packages continue to deduplicate by package name as expected

Testing

Verified with real-world setup using four local file:// plugins, all with index.js or index.mjs entry points:

  • custom-toolkit-alpha/dist/index.js
  • custom-toolkit-beta/dist/index.js
  • custom-toolkit-gamma/dist/index.js
  • custom-auth-plugin/index.mjs

Before fix: Only the last plugin loaded.
After fix: All four plugins load correctly.

Previously, file:// plugins were deduplicated by filename only, causing
issues when multiple plugins used standard entry points like index.js.

Example problem:
  file:///path/to/plugin-a/dist/index.js -> 'index'
  file:///path/to/plugin-b/dist/index.js -> 'index'
  Result: Only last plugin loaded (seen as duplicates)

This change uses the full URL as the identity for file:// plugins,
allowing multiple plugins in the same or different directories with
the same filename.

Fixes deduplication logic to support:
- Multiple plugins in same directory (github-tools.js, db-tools.js)
- Multiple plugins with standard entry points (all named index.js)
- Same file:// URL appearing twice still deduplicates correctly

npm packages continue to deduplicate by package name as expected.
@github-actions
Copy link
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions
Copy link
Contributor

The following comment was made by an LLM, it may be inaccurate:

No duplicate PRs found

@coleleavitt
Copy link
Contributor Author

Fixes #8759

Comment on lines 343 to +344
if (plugin.startsWith("file://")) {
return path.parse(new URL(plugin).pathname).name
return plugin
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But now someone w/ oh-my-opencode installed that is working in oh-my-opencode repo will have the plugn registered twice right

Fixes plugin deduplication issue where multiple file:// plugins with
generic filenames (index.js, main.js) would collide. Now extracts the
parent directory name when filename is generic (index, main, plugin,
dist, build, out, lib), allowing both proper deduplication of npm vs
file:// versions of the same plugin AND multiple generic-named plugins
in different directories.

Examples:
- file:///.../plugin-a/dist/index.js -> 'plugin-a'
- file:///.../plugin-b/dist/index.js -> 'plugin-b'
- file:///.../oh-my-opencode/dist/index.js -> 'oh-my-opencode'
- oh-my-opencode@2.4.3 -> 'oh-my-opencode' (deduplicates correctly)

Addresses reviewer concern about oh-my-opencode npm + file:// loading
twice during local development.
Primary method now looks for package.json in parent directories (up to 5
levels) and extracts the 'name' field. Falls back to the heuristic
approach (filename or parent directory) when package.json is not found.

This is more robust because:
- Uses canonical package name from package.json
- Handles scoped packages correctly (@scope/name)
- Self-maintaining (no hardcoded list to update)
- Works for real npm package development workflows

Added tests for:
- Extracting name from package.json
- Preferring package.json name over directory name
… lookup

- Use fileURLToPath() instead of URL.pathname for proper Windows support
- Use path.sep for platform-specific path separators
- Remove package.json lookup (overcomplicated per maintainer feedback)
- Keep simple heuristic: skip generic names (index, dist, etc.) to find meaningful parent dir
@coleleavitt
Copy link
Contributor Author

@rekram1-node Re: your review comment about duplicate registration when working in oh-my-opencode repo:

Good catch. Yes, that's true - if someone has oh-my-opencode configured as a plugin AND is working inside the oh-my-opencode directory (which has its own opencode.json referencing itself), the plugin would load twice.

However, this is actually the existing behavior that my PR preserves. The deduplication happens by checking seen.has(name) - if two different file:// URLs point to the same plugin, they're still different strings and both load.

The alternative would be to resolve to an absolute canonical path and dedupe on that, but that adds complexity and might break legitimate use cases (symlinks, mounted volumes, etc.).

Should I add canonical path resolution, or is the current behavior acceptable?

…ames

The package.json approach is superior to path heuristics because:

1. Cross-platform: Avoids path.sep issues that break on Windows
2. Canonical: Uses the actual package name, not guessed from paths
3. Deduplication: Fixes duplicate registration when same plugin loaded
   from different paths (e.g., global config + local repo)
4. Proper scoped package support: Handles @scope/name correctly

Windows users report 40+ test failures with file:// plugins due to
path.sep usage. Reading package.json solves this fundamental issue.

Fallback to path heuristic when package.json not found.
@coleleavitt
Copy link
Contributor Author

Updated based on feedback from Discord user @ops:

What Changed

Restored the package.json lookup that was previously removed. This is the correct approach.

Why package.json is Better

  1. Cross-platform: Avoids path.sep issues that cause 40+ test failures on Windows
  2. Canonical naming: Uses actual package name instead of guessing from paths
  3. Fixes duplicate registration: Same plugin loaded from different paths (global + local repo) now dedupes correctly because they share the same package.json name
  4. Proper scoped packages: Handles @scope/name correctly

The Windows Problem

@ops pointed out that file:// plugins are completely broken on Windows. The issue:

  • filePath.split(path.sep) mixes OS-specific paths (\ on Windows) with URL paths (always /)
  • This breaks path parsing and causes widespread test failures

The Solution

Read the canonical name from package.json:

// file:///home/user/.opencode/plugins/oh-my-opencode/dist/index.js
// → reads package.json → "oh-my-opencode"

// file:///home/user/projects/oh-my-opencode/dist/index.js  
// → reads package.json → "oh-my-opencode"

// Both dedupe correctly now!

Fallback to path heuristic when package.json not found.

This addresses both the Windows compatibility issue and the duplicate registration concern raised by @rekram1-node.

@cyberprophet
Copy link

FYI — I've opened #15598 with a similar fix but a different approach: instead of using the full URL as identity, it extracts the npm package name from node_modules paths (handling @scoped packages too) while keeping the existing filename fallback for local plugins. CI is passing.

This preserves the original design intent where a local my-plugin.js can override an npm my-plugin@1.0.0 by name, while fixing the index.js collision.

@coleleavitt
Copy link
Contributor Author

Superseded by #16200 which is rebased on latest dev and uses a cleaner implementation (package.json lookup + realpath fallback).

@coleleavitt
Copy link
Contributor Author

Closing in favor of #16200

@coleleavitt coleleavitt closed this Mar 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants