fix(cli): expanded marketplace catalog home paths

Resolved persisted home-relative marketplace catalog paths before cache reads and writes, and migrated them to absolute paths on update.

Added a regression test covering cwd-relative literal tilde writes.

Fixes #8627
This commit is contained in:
roboomp
2026-08-15 08:17:54 +00:00
parent ffd53ff92a
commit 99cd009285
3 changed files with 49 additions and 18 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Expanded home-relative marketplace catalog paths before cache access, preventing updates from writing into a literal `~` directory under the process working directory ([#8627](https://github.com/can1357/oh-my-pi/issues/8627)).
## [17.3.4] - 2026-08-14
### Changed
@@ -11,6 +11,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
import { expandTilde } from "../../../tools/path-utils";
import { normalizePluginRuntimeConfig } from "../runtime-config";
import type { PluginRuntimeConfig } from "../types";
@@ -107,12 +108,11 @@ export class MarketplaceManager {
}
const sourceType = classifySource(source);
const normalizedSource =
sourceType === "local"
? path.resolve(source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source)
: source;
const normalizedSource = sourceType === "local" ? path.resolve(expandTilde(source)) : source;
const catalogPath = path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json");
const catalogPath = path.resolve(
expandTilde(path.join(this.#opts.marketplacesCacheDir, catalog.name, "marketplace.json")),
);
// Persist the fetched catalog so subsequent reads don't require re-fetching.
await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
@@ -174,11 +174,13 @@ export class MarketplaceManager {
await promoteCloneToCache(clonePath, this.#opts.marketplacesCacheDir, catalog.name);
}
// Overwrite cached catalog
await Bun.write(existing.catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
// Overwrite the cached catalog and migrate legacy home-relative registry entries.
const catalogPath = path.resolve(expandTilde(existing.catalogPath));
await Bun.write(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
const updatedEntry: MarketplaceRegistryEntry = {
...existing,
catalogPath,
updatedAt: new Date().toISOString(),
};
@@ -893,14 +895,13 @@ export class MarketplaceManager {
}
async #readCatalog(entry: MarketplaceRegistryEntry): Promise<MarketplaceCatalog> {
const catalogPath = path.resolve(expandTilde(entry.catalogPath));
try {
const content = await Bun.file(entry.catalogPath).text();
return parseMarketplaceCatalog(content, entry.catalogPath);
const content = await Bun.file(catalogPath).text();
return parseMarketplaceCatalog(content, catalogPath);
} catch (err) {
if (isEnoent(err)) {
throw new Error(
`Marketplace catalog not found at ${entry.catalogPath}. Try: /marketplace update ${entry.name}`,
);
throw new Error(`Marketplace catalog not found at ${catalogPath}. Try: /marketplace update ${entry.name}`);
}
throw err;
}
@@ -918,14 +919,10 @@ export class MarketplaceManager {
*/
#resolveMarketplaceRoot(entry: MarketplaceRegistryEntry): string {
if (entry.sourceType === "local") {
// expandHome already happened in fetcher; resolve to ensure absolute.
const expanded = entry.sourceUri.startsWith("~/")
? path.join(os.homedir(), entry.sourceUri.slice(2))
: entry.sourceUri;
return path.resolve(expanded);
return path.resolve(expandTilde(entry.sourceUri));
}
// For git/github/url sources, the catalog lives at <cloneDir>/marketplace.json
// under marketplacesCacheDir/<name>/; parent = <marketplacesCacheDir>/<name>/
return path.dirname(entry.catalogPath);
return path.dirname(path.resolve(expandTilde(entry.catalogPath)));
}
}
@@ -9,6 +9,8 @@ import { PluginManager } from "@oh-my-pi/pi-coding-agent/extensibility/plugins/m
import {
MarketplaceManager,
readInstalledPluginsRegistry,
readMarketplacesRegistry,
writeMarketplacesRegistry,
} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/marketplace";
import * as piUtils from "@oh-my-pi/pi-utils";
import { removeSyncWithRetries } from "@oh-my-pi/pi-utils";
@@ -168,6 +170,34 @@ describe("MarketplaceManager", () => {
expect(new Date(updated.updatedAt) >= new Date(added.addedAt)).toBe(true);
});
it("updateMarketplace expands a home-relative catalog path", async () => {
const fakeHome = fs.mkdtempSync(path.join(ctx.tmpDir, "home-"));
const homedirSpy = spyOn(os, "homedir").mockReturnValue(fakeHome);
const registryPath = path.join(ctx.tmpDir, "marketplaces.json");
const originalCwd = process.cwd();
const cwd = path.join(ctx.tmpDir, "cwd");
try {
const added = await ctx.manager.addMarketplace(FIXTURE_DIR);
const catalogPath = "~/.omp/plugins/cache/marketplaces/test-marketplace/marketplace.json";
const registry = await readMarketplacesRegistry(registryPath);
await writeMarketplacesRegistry(registryPath, {
...registry,
marketplaces: [{ ...added, catalogPath }],
});
fs.mkdirSync(cwd);
process.chdir(cwd);
await ctx.manager.updateMarketplace("test-marketplace");
expect(fs.existsSync(path.join(fakeHome, catalogPath.slice(2)))).toBe(true);
expect(fs.existsSync(path.join(cwd, catalogPath))).toBe(false);
} finally {
process.chdir(originalCwd);
homedirSpy.mockRestore();
}
});
// ── Plugin discovery ───────────────────────────────────────────────────
it("listAvailablePlugins → returns catalog entries", async () => {