fix: address Codex review findings

- Honor enabled:false flag in listClaudePluginRoots (skip disabled plugins)
- Normalize local source URIs to absolute paths in addMarketplace
- Resolver returns explicit tempCloneRoot instead of path-based inference
This commit is contained in:
Miroslav Drbal
2026-03-30 13:44:53 +02:00
committed by can1357
parent dc98266281
commit 505b671c77
4 changed files with 32 additions and 18 deletions
@@ -594,6 +594,7 @@ export interface ClaudePluginEntry {
installedAt: string;
lastUpdated: string;
gitCommitSha?: string;
enabled?: boolean;
}
/**
@@ -682,6 +683,7 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau
warnings.push(`Plugin ${pluginId} entry has no installPath`);
continue;
}
if (entry.enabled === false) continue;
roots.push({
id: pluginId,
@@ -725,6 +727,7 @@ export async function listClaudePluginRoots(home: string): Promise<{ roots: Clau
warnings.push(`Plugin ${pluginId} entry has no installPath`);
continue;
}
if (entry.enabled === false) continue;
// Deduplicate by installPath within same ID
if (roots.some(r => r.id === pluginId && r.path === entry.installPath)) continue;
@@ -10,7 +10,7 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import { cachePlugin } from "./cache";
import { classifySource, fetchMarketplace, parseMarketplaceCatalog } from "./fetcher";
@@ -60,6 +60,10 @@ export class MarketplaceManager {
async addMarketplace(source: string): Promise<MarketplaceRegistryEntry> {
const { catalog } = await fetchMarketplace(source, this.#opts.marketplacesCacheDir);
const sourceType = classifySource(source);
const normalizedSource =
sourceType === "local"
? path.resolve(source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source)
: source;
const catalogPath = path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json");
@@ -70,7 +74,7 @@ export class MarketplaceManager {
const entry: MarketplaceRegistryEntry = {
name: catalog.name,
sourceType,
sourceUri: source,
sourceUri: normalizedSource,
catalogPath,
addedAt: now,
updatedAt: now,
@@ -222,12 +226,11 @@ export class MarketplaceManager {
// sourceUri. For local sources, use path.resolve of sourceUri; for others use the cache dir.
const marketplaceClonePath = this.#resolveMarketplaceRoot(mktEntry);
const sourcePath = await resolvePluginSource(pluginEntry, {
const { dir: sourcePath, tempCloneRoot } = await resolvePluginSource(pluginEntry, {
marketplaceClonePath,
catalogMetadata: catalog.metadata,
tmpDir: os.tmpdir(),
});
const isTemp = pathIsWithin(os.tmpdir(), sourcePath);
// 5. Determine version: catalog entry > plugin manifest > git SHA > fallback
let version!: string;
@@ -237,11 +240,8 @@ export class MarketplaceManager {
cachePath = await cachePlugin(sourcePath, this.#opts.pluginsCacheDir, marketplace, name, version);
} finally {
// Clean up temp clone dirs created by resolvePluginSource; leave user-supplied local dirs alone
if (isTemp) {
// For git-subdir, sourcePath may be a subdir of the temp clone — clean the whole clone root.
const rel = path.relative(os.tmpdir(), sourcePath);
const tempRoot = path.join(os.tmpdir(), rel.split(path.sep)[0]);
await fs.rm(tempRoot, { recursive: true, force: true }).catch(() => {});
if (tempCloneRoot) {
await fs.rm(tempCloneRoot, { recursive: true, force: true }).catch(() => {});
}
}
@@ -32,7 +32,10 @@ export interface ResolveContext {
*
* The resolved path is verified to exist on disk.
*/
export async function resolvePluginSource(entry: MarketplacePluginEntry, context: ResolveContext): Promise<string> {
export async function resolvePluginSource(
entry: MarketplacePluginEntry,
context: ResolveContext,
): Promise<{ dir: string; tempCloneRoot?: string }> {
const { source } = entry;
if (typeof source === "string") {
@@ -44,7 +47,10 @@ export async function resolvePluginSource(entry: MarketplacePluginEntry, context
// ── Relative string source ("./plugins/foo") ────────────────────────
async function resolveRelativeSource(source: string, context: ResolveContext): Promise<string> {
async function resolveRelativeSource(
source: string,
context: ResolveContext,
): Promise<{ dir: string; tempCloneRoot?: string }> {
if (!source.startsWith("./")) {
throw new Error(`Relative plugin source paths must start with "./" — got: "${source}"`);
}
@@ -67,19 +73,22 @@ async function resolveRelativeSource(source: string, context: ResolveContext): P
}
await verifyDirExists(resolved, `Plugin source directory does not exist: "${resolved}"`);
return resolved;
return { dir: resolved };
}
// ── Object source variants ──────────────────────────────────────────
async function resolveObjectSource(source: Exclude<PluginSource, string>, context: ResolveContext): Promise<string> {
async function resolveObjectSource(
source: Exclude<PluginSource, string>,
context: ResolveContext,
): Promise<{ dir: string; tempCloneRoot?: string }> {
switch (source.source) {
case "url": {
// { source: "url", url: "https://github.com/owner/repo.git" }
// Despite the name, this is typically a git clone URL
const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`);
await cloneGitRepo(source.url, targetDir, { ref: source.ref, sha: source.sha });
return targetDir;
return { dir: targetDir, tempCloneRoot: targetDir };
}
case "github": {
@@ -87,7 +96,7 @@ async function resolveObjectSource(source: Exclude<PluginSource, string>, contex
const url = `https://github.com/${source.repo}.git`;
const targetDir = path.join(context.tmpDir, `plugin-${crypto.randomUUID()}`);
await cloneGitRepo(url, targetDir, { ref: source.ref, sha: source.sha });
return targetDir;
return { dir: targetDir, tempCloneRoot: targetDir };
}
case "git-subdir": {
@@ -105,7 +114,7 @@ async function resolveObjectSource(source: Exclude<PluginSource, string>, contex
throw new Error(`git-subdir path "${source.path}" escapes the cloned repository`);
}
await verifyDirExists(subdirPath, `git-subdir path "${source.path}" does not exist in cloned repository`);
return subdirPath;
return { dir: subdirPath, tempCloneRoot: cloneDir };
}
case "npm":
@@ -31,7 +31,8 @@ describe("resolvePluginSource", () => {
marketplaceClonePath: FIXTURE_DIR,
tmpDir,
});
expect(resolved).toBe(path.resolve(FIXTURE_DIR, "plugins/hello-plugin"));
expect(resolved.dir).toBe(path.resolve(FIXTURE_DIR, "plugins/hello-plugin"));
expect(resolved.tempCloneRoot).toBeUndefined();
});
it("throws when source string would escape marketplace root", async () => {
@@ -61,7 +62,8 @@ describe("resolvePluginSource", () => {
catalogMetadata: { pluginRoot: "plugins" },
tmpDir,
});
expect(resolved).toBe(path.resolve(FIXTURE_DIR, "plugins/hello-plugin"));
expect(resolved.dir).toBe(path.resolve(FIXTURE_DIR, "plugins/hello-plugin"));
expect(resolved.tempCloneRoot).toBeUndefined();
});
// Network-dependent: object sources attempt real git clones