diff --git a/src/cli/commands/plugin.ts b/src/cli/commands/plugin.ts index 198b5a0f..4a4a962f 100644 --- a/src/cli/commands/plugin.ts +++ b/src/cli/commands/plugin.ts @@ -9,6 +9,12 @@ import { parsePluginSpec, getAllagentsDir, getMarketplaceVersion, + listMarketplacesWithScope, + getRegistryPath, + getProjectRegistryPath, + loadRegistryFromPath, + type ScopedMarketplaceEntry, + getMarketplaceOverrides, } from '../../core/marketplace.js'; import { syncWorkspace, syncUserWorkspace } from '../../core/sync.js'; import { loadSyncState } from '../../core/sync-state.js'; @@ -220,10 +226,36 @@ async function runUserSyncAndPrint(): Promise<{ ok: boolean; syncData: ReturnTyp const marketplaceListCmd = command({ name: 'list', description: buildDescription(marketplaceListMeta), - args: {}, - handler: async () => { + args: { + scope: option({ type: optional(string), long: 'scope', short: 's', description: 'Filter by scope: user or project' }), + }, + handler: async ({ scope }) => { try { - const marketplaces = await listMarketplaces(); + if (scope && scope !== 'user' && scope !== 'project') { + const msg = `Invalid scope '${scope}'. Must be 'user' or 'project'.`; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace list', error: msg }); + process.exit(1); + } + console.error(`Error: ${msg}`); + process.exit(1); + } + + let marketplaces: ScopedMarketplaceEntry[]; + let overrideNames: string[] = []; + + if (!scope) { + // Default: show all scopes merged + const scopedResult = await listMarketplacesWithScope(getRegistryPath(), getProjectRegistryPath(process.cwd())); + marketplaces = scopedResult.entries; + overrideNames = scopedResult.overrides; + } else if (scope === 'user') { + const registry = await loadRegistryFromPath(getRegistryPath()); + marketplaces = Object.values(registry.marketplaces).map((mp) => ({ ...mp, scope: 'user' as const })); + } else { + const registry = await loadRegistryFromPath(getProjectRegistryPath(process.cwd())); + marketplaces = Object.values(registry.marketplaces).map((mp) => ({ ...mp, scope: 'project' as const })); + } if (isJsonMode()) { const enriched = await Promise.all( @@ -246,6 +278,11 @@ const marketplaceListCmd = command({ return; } + // Emit override warnings when listing all scopes + for (const overrideName of overrideNames) { + console.warn(`Warning: Workspace marketplace '${overrideName}' overrides user marketplace of the same name.`); + } + if (marketplaces.length === 0) { console.log('No marketplaces registered.\n'); console.log('Add a marketplace with:'); @@ -271,7 +308,7 @@ const marketplaceListCmd = command({ sourceLabel = `Local: ${mp.source.location}`; } - console.log(` ❯ ${mp.name}`); + console.log(` ❯ ${mp.name} (${mp.scope})`); console.log(` Source: ${sourceLabel}`); const version = await getMarketplaceVersion(mp.path); @@ -309,14 +346,41 @@ const marketplaceAddCmd = command({ source: positional({ type: string, displayName: 'source' }), name: option({ type: optional(string), long: 'name', short: 'n', description: 'Custom name for the marketplace' }), branch: option({ type: optional(string), long: 'branch', short: 'b', description: 'Branch to checkout after cloning' }), + scope: option({ type: optional(string), long: 'scope', short: 's', description: 'Scope: user (default) or project' }), }, - handler: async ({ source, name, branch }) => { + handler: async ({ source, name, branch, scope }) => { try { + const effectiveScope = (scope ?? 'user') as import('../../core/marketplace.js').MarketplaceScope; + if (effectiveScope !== 'user' && effectiveScope !== 'project') { + const msg = `Invalid scope '${scope}'. Must be 'user' or 'project'.`; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace add', error: msg }); + process.exit(1); + } + console.error(`Error: ${msg}`); + process.exit(1); + } + + if (effectiveScope === 'project') { + if (!existsSync(join(process.cwd(), CONFIG_DIR, WORKSPACE_CONFIG_FILE))) { + const msg = 'No workspace found in current directory. Run "allagents workspace init" first.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace add', error: msg }); + process.exit(1); + } + console.error(`Error: ${msg}`); + process.exit(1); + } + } + if (!isJsonMode()) { console.log(`Adding marketplace: ${source}...`); } - const result = await addMarketplace(source, name, branch); + const result = await addMarketplace(source, name, branch, { + scope: effectiveScope, + workspacePath: process.cwd(), + }); if (!result.success) { if (isJsonMode()) { @@ -366,10 +430,27 @@ const marketplaceRemoveCmd = command({ description: buildDescription(marketplaceRemoveMeta), args: { name: positional({ type: string, displayName: 'name' }), + scope: option({ type: optional(string), long: 'scope', short: 's', description: 'Filter by scope: user or project (default: removes from both)' }), }, - handler: async ({ name }) => { + handler: async ({ name, scope }) => { try { - const result = await removeMarketplace(name); + if (scope && scope !== 'user' && scope !== 'project') { + const msg = `Invalid scope '${scope}'. Must be 'user' or 'project'.`; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace remove', error: msg }); + process.exit(1); + } + console.error(`Error: ${msg}`); + process.exit(1); + } + + // No --scope: remove from both; --scope user/project: remove from that scope only + const effectiveScope = (scope ?? 'all') as import('../../core/marketplace.js').MarketplaceScope | 'all'; + + const result = await removeMarketplace(name, { + scope: effectiveScope, + workspacePath: process.cwd(), + }); if (!result.success) { if (isJsonMode()) { @@ -436,7 +517,7 @@ const marketplaceUpdateCmd = command({ console.log(); } - const results = await updateMarketplace(name); + const results = await updateMarketplace(name, process.cwd()); if (isJsonMode()) { const succeeded = results.filter((r) => r.success).length; @@ -503,7 +584,7 @@ const marketplaceBrowseCmd = command({ }, handler: async ({ name }) => { try { - if (!await findMarketplace(name)) { + if (!await findMarketplace(name, undefined, process.cwd())) { const error = `Marketplace '${name}' not found`; if (isJsonMode()) { jsonOutput({ success: false, command: 'plugin marketplace browse', error }); @@ -515,7 +596,7 @@ const marketplaceBrowseCmd = command({ process.exit(1); } - const result = await listMarketplacePlugins(name); + const result = await listMarketplacePlugins(name, process.cwd()); // Build installed lookup const userPlugins = await getInstalledUserPlugins(); @@ -852,6 +933,17 @@ const pluginInstallCmd = command({ } } + // Emit override warnings for project-scope installs + if (!isUser) { + const overrideNames = await getMarketplaceOverrides( + getRegistryPath(), + getProjectRegistryPath(process.cwd()), + ); + for (const name of overrideNames) { + console.warn(`Warning: Workspace marketplace '${name}' overrides user marketplace of the same name.`); + } + } + const result = isUser ? await addUserPlugin(plugin) : await addPlugin(plugin); diff --git a/src/core/marketplace.ts b/src/core/marketplace.ts index f97bab30..30ab7b9a 100644 --- a/src/core/marketplace.ts +++ b/src/core/marketplace.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs'; import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; -import { basename, join, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import simpleGit from 'simple-git'; import { getHomeDir } from '../constants.js'; import { @@ -96,11 +96,18 @@ export function getRegistryPath(): string { } /** - * Load marketplace registry from disk + * Get the project-level registry file path */ -export async function loadRegistry(): Promise { - const registryPath = getRegistryPath(); +export function getProjectRegistryPath(workspacePath: string): string { + return join(workspacePath, '.allagents', 'marketplaces.json'); +} +/** + * Load marketplace registry from a specific file path + */ +export async function loadRegistryFromPath( + registryPath: string, +): Promise { if (!existsSync(registryPath)) { return { version: 1, marketplaces: {} }; } @@ -114,13 +121,13 @@ export async function loadRegistry(): Promise { } /** - * Save marketplace registry to disk + * Save marketplace registry to a specific file path */ -export async function saveRegistry( +export async function saveRegistryToPath( registry: MarketplaceRegistry, + registryPath: string, ): Promise { - const registryPath = getRegistryPath(); - const dir = getAllagentsDir(); + const dir = dirname(registryPath); if (!existsSync(dir)) { await mkdir(dir, { recursive: true }); @@ -129,6 +136,22 @@ export async function saveRegistry( await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`); } +/** + * Load marketplace registry from disk + */ +export async function loadRegistry(): Promise { + return loadRegistryFromPath(getRegistryPath()); +} + +/** + * Save marketplace registry to disk + */ +export async function saveRegistry( + registry: MarketplaceRegistry, +): Promise { + return saveRegistryToPath(registry, getRegistryPath()); +} + /** * Get the source location key for a marketplace. * Each branch is treated as a separate marketplace, so the full location @@ -226,17 +249,28 @@ export function parseMarketplaceSource(source: string): { }; } +/** + * Options for specifying marketplace scope + */ +export interface MarketplaceScopeOptions { + scope?: MarketplaceScope; + workspacePath?: string; +} + /** * Add a marketplace to the registry * Idempotent: returns success if marketplace is already registered by source location * * @param source - Marketplace source (URL, path, or name) * @param customName - Optional custom name for the marketplace + * @param branch - Optional branch to checkout + * @param scopeOptions - Optional scope options (user or project) */ export async function addMarketplace( source: string, customName?: string, branch?: string, + scopeOptions?: MarketplaceScopeOptions, ): Promise { const parsed = parseMarketplaceSource(source); @@ -267,7 +301,10 @@ export async function addMarketplace( } let name = customName || parsed.name; - const registry = await loadRegistry(); + const registryPath = scopeOptions?.scope === 'project' && scopeOptions?.workspacePath + ? getProjectRegistryPath(scopeOptions.workspacePath) + : getRegistryPath(); + const registry = await loadRegistryFromPath(registryPath); // Check if already registered by name if (registry.marketplaces[name]) { @@ -399,7 +436,7 @@ export async function addMarketplace( // Save to registry registry.marketplaces[name] = entry; - await saveRegistry(registry); + await saveRegistryToPath(registry, registryPath); return { success: true, @@ -413,29 +450,72 @@ export async function addMarketplace( * By default, user-level plugins referencing the marketplace are **retained** * (listed in `retainedUserPlugins`). Pass `{ cascade: true }` to remove them * (listed in `removedUserPlugins`). + * + * @param name - Marketplace name to remove + * @param options.cascade - Remove user-level plugins referencing this marketplace + * @param options.scope - Scope to remove from: 'user', 'project', or 'all' (default) + * @param options.workspacePath - Project path (required for project/all scope) + * @param options.userRegistryPath - Override user registry path (for testing) */ export async function removeMarketplace( name: string, - options: { cascade?: boolean } = {}, + options: { + cascade?: boolean; + scope?: MarketplaceScope | 'all'; + workspacePath?: string; + userRegistryPath?: string; + } = {}, ): Promise { - const registry = await loadRegistry(); + const scope = options.scope ?? 'all'; + + // Guard: project scope requires workspacePath + if ((scope === 'project' || scope === 'all') && !options.workspacePath && !options.userRegistryPath) { + if (scope === 'project') { + return { + success: false, + error: 'workspacePath is required when scope is "project"', + }; + } + // scope === 'all' without workspacePath: fall back to user-only removal + } + + const userRegPath = options.userRegistryPath ?? getRegistryPath(); + let removedEntry: MarketplaceEntry | undefined; + + // Remove from user scope + if (scope === 'user' || scope === 'all') { + const userRegistry = await loadRegistryFromPath(userRegPath); + if (userRegistry.marketplaces[name]) { + removedEntry = userRegistry.marketplaces[name]; + delete userRegistry.marketplaces[name]; + await saveRegistryToPath(userRegistry, userRegPath); + if (removedEntry.source.type !== 'local' && existsSync(removedEntry.path)) { + await rm(removedEntry.path, { recursive: true, force: true }); + } + } + } + + // Remove from project scope + if ((scope === 'project' || scope === 'all') && options.workspacePath) { + const projectRegPath = getProjectRegistryPath(options.workspacePath); + const projectRegistry = await loadRegistryFromPath(projectRegPath); + if (projectRegistry.marketplaces[name]) { + removedEntry = projectRegistry.marketplaces[name]; + delete projectRegistry.marketplaces[name]; + await saveRegistryToPath(projectRegistry, projectRegPath); + if (removedEntry.source.type !== 'local' && existsSync(removedEntry.path)) { + await rm(removedEntry.path, { recursive: true, force: true }); + } + } + } - if (!registry.marketplaces[name]) { + if (!removedEntry) { return { success: false, error: `Marketplace '${name}' not found in registry`, }; } - const entry = registry.marketplaces[name]; - delete registry.marketplaces[name]; - await saveRegistry(registry); - - // Delete the cached directory (only for cloned GitHub marketplaces, not local paths) - if (entry.source.type !== 'local' && existsSync(entry.path)) { - await rm(entry.path, { recursive: true, force: true }); - } - if (options.cascade) { // Cascade: remove user-level plugins referencing this marketplace const { removeUserPluginsForMarketplace } = await import( @@ -445,7 +525,7 @@ export async function removeMarketplace( return { success: true, - marketplace: entry, + marketplace: removedEntry, removedUserPlugins, }; } @@ -458,7 +538,7 @@ export async function removeMarketplace( return { success: true, - marketplace: entry, + marketplace: removedEntry, retainedUserPlugins, }; } @@ -478,7 +558,15 @@ export async function listMarketplaces(): Promise { */ export async function getMarketplace( name: string, + workspacePath?: string, ): Promise { + if (workspacePath) { + const { registry } = await loadMergedRegistries( + getRegistryPath(), + getProjectRegistryPath(workspacePath), + ); + return registry.marketplaces[name] || null; + } const registry = await loadRegistry(); return registry.marketplaces[name] || null; } @@ -490,8 +578,11 @@ export async function getMarketplace( export async function findMarketplace( name: string, sourceLocation?: string, + workspacePath?: string, ): Promise { - const registry = await loadRegistry(); + const registry = workspacePath + ? (await loadMergedRegistries(getRegistryPath(), getProjectRegistryPath(workspacePath))).registry + : await loadRegistry(); if (registry.marketplaces[name]) { return registry.marketplaces[name]; } @@ -507,15 +598,37 @@ export async function findMarketplace( */ export async function updateMarketplace( name?: string, + workspacePath?: string, ): Promise> { - const registry = await loadRegistry(); - const results: Array<{ name: string; success: boolean; error?: string }> = []; + const userRegistry = await loadRegistry(); + let projectRegistry: MarketplaceRegistry | undefined; + + if (workspacePath) { + const projectPath = getProjectRegistryPath(workspacePath); + if (existsSync(projectPath)) { + projectRegistry = await loadRegistryFromPath(projectPath); + } + } - const toUpdate = name - ? registry.marketplaces[name] - ? [registry.marketplaces[name]] + // Merge for lookup, tracking which scope each entry came from + const mergedEntries = new Map(); + for (const entry of Object.values(userRegistry.marketplaces)) { + mergedEntries.set(entry.name, { entry, scope: 'user' }); + } + if (projectRegistry) { + for (const entry of Object.values(projectRegistry.marketplaces)) { + mergedEntries.set(entry.name, { entry, scope: 'project' }); + } + } + + const toUpdateScoped = name + ? mergedEntries.has(name) + ? [mergedEntries.get(name)!] : [] - : Object.values(registry.marketplaces); + : Array.from(mergedEntries.values()); + + const toUpdate = toUpdateScoped.map((s) => s.entry); + const results: Array<{ name: string; success: boolean; error?: string }> = []; if (name && toUpdate.length === 0) { return [{ name, success: false, error: `Marketplace '${name}' not found` }]; @@ -581,9 +694,8 @@ export async function updateMarketplace( await git.checkout(targetBranch); await pull(marketplace.path); - // Update lastUpdated in registry + // Update lastUpdated in the entry (mutates in place for scope tracking) marketplace.lastUpdated = new Date().toISOString(); - registry.marketplaces[marketplace.name] = marketplace; results.push({ name: marketplace.name, @@ -598,8 +710,24 @@ export async function updateMarketplace( } } - // Save updated timestamps - await saveRegistry(registry); + // Save updated timestamps back to the appropriate registries + let userDirty = false; + let projectDirty = false; + for (const { entry, scope } of toUpdateScoped) { + if (scope === 'user') { + userRegistry.marketplaces[entry.name] = entry; + userDirty = true; + } else if (projectRegistry) { + projectRegistry.marketplaces[entry.name] = entry; + projectDirty = true; + } + } + if (userDirty) { + await saveRegistry(userRegistry); + } + if (projectDirty && projectRegistry && workspacePath) { + await saveRegistryToPath(projectRegistry, getProjectRegistryPath(workspacePath)); + } return results; } @@ -675,8 +803,9 @@ export async function getMarketplacePluginsFromManifest( */ export async function listMarketplacePlugins( name: string, + workspacePath?: string, ): Promise { - const marketplace = await getMarketplace(name); + const marketplace = await getMarketplace(name, workspacePath); if (!marketplace) { return { plugins: [], warnings: [] }; } @@ -787,6 +916,7 @@ export async function resolvePluginSpec( marketplacePathOverride?: string; offline?: boolean; fetchFn?: (url: string) => Promise; + workspacePath?: string; } = {}, ): Promise<{ path: string; marketplace: string; plugin: string } | null> { const parsed = parsePluginSpec(spec); @@ -801,7 +931,7 @@ export async function resolvePluginSpec( // Determine marketplace path: use override or look up from registry let marketplacePath: string | null = options.marketplacePathOverride ?? null; if (!marketplacePath) { - const marketplace = await getMarketplace(marketplaceName); + const marketplace = await getMarketplace(marketplaceName, options.workspacePath); if (!marketplace) { return null; } @@ -927,7 +1057,7 @@ async function refreshMarketplace( */ export async function resolvePluginSpecWithAutoRegister( spec: string, - options: { offline?: boolean } = {}, + options: { offline?: boolean; workspacePath?: string } = {}, ): Promise { // Parse plugin@marketplace using the parser const parsed = parsePluginSpec(spec); @@ -943,7 +1073,7 @@ export async function resolvePluginSpecWithAutoRegister( // Check if marketplace is already registered (by name, then by source location) const sourceLocation = owner && repo ? `${owner}/${repo}` : undefined; - let marketplace = await findMarketplace(marketplaceName, sourceLocation); + let marketplace = await findMarketplace(marketplaceName, sourceLocation, options.workspacePath); let didAutoRegister = false; // If not registered, try auto-registration @@ -957,7 +1087,7 @@ export async function resolvePluginSpecWithAutoRegister( error: autoRegResult.error || 'Unknown error', }; } - marketplace = await getMarketplace(autoRegResult.name ?? marketplaceName); + marketplace = await getMarketplace(autoRegResult.name ?? marketplaceName, options.workspacePath); didAutoRegister = true; } @@ -975,7 +1105,7 @@ export async function resolvePluginSpecWithAutoRegister( marketplace.source.type !== 'local' && !updatedMarketplaceCache.has(marketplace.name) ) { - const results = await updateMarketplace(marketplace.name); + const results = await updateMarketplace(marketplace.name, options.workspacePath); const result = results[0]; if (result?.success) { updatedMarketplaceCache.add(marketplace.name); @@ -995,6 +1125,7 @@ export async function resolvePluginSpecWithAutoRegister( ...(subpath && { subpath }), marketplaceNameOverride: marketplace.name, ...(options.offline != null && { offline: options.offline }), + ...(options.workspacePath && { workspacePath: options.workspacePath }), }; let resolved = await resolvePluginSpec(spec, resolveOpts); @@ -1010,6 +1141,7 @@ export async function resolvePluginSpecWithAutoRegister( resolved = await resolvePluginSpec(spec, { ...(subpath && { subpath }), marketplaceNameOverride: marketplace.name, + ...(options.workspacePath && { workspacePath: options.workspacePath }), }); } } @@ -1172,6 +1304,119 @@ export async function ensureMarketplacesRegistered( return results; } +/** + * Scope of a marketplace entry (user-level or project-level) + */ +export type MarketplaceScope = 'user' | 'project'; + +/** + * Result of merging user and project registries + */ +export interface MergedRegistriesResult { + registry: MarketplaceRegistry; + /** Marketplace names where project overrides user */ + overrides: string[]; +} + +/** + * Load and merge user and project registries. + * Project entries take precedence over user entries on name collision. + */ +export async function loadMergedRegistries( + userRegistryPath: string, + projectRegistryPath: string, +): Promise { + const [userRegistry, projectRegistry] = await Promise.all([ + loadRegistryFromPath(userRegistryPath), + loadRegistryFromPath(projectRegistryPath), + ]); + + const merged: MarketplaceRegistry = { + version: 1, + marketplaces: { ...userRegistry.marketplaces }, + }; + + const overrides: string[] = []; + + for (const [name, entry] of Object.entries(projectRegistry.marketplaces)) { + if (merged.marketplaces[name]) { + overrides.push(name); + } + merged.marketplaces[name] = entry; + } + + return { registry: merged, overrides }; +} + +/** + * Check for marketplace overrides where a project registry entry + * shadows a user registry entry of the same name. + * Returns the list of overridden marketplace names. + */ +export async function getMarketplaceOverrides( + userRegistryPath: string, + projectRegistryPath: string, +): Promise { + if (!existsSync(projectRegistryPath)) { + return []; + } + const { overrides } = await loadMergedRegistries(userRegistryPath, projectRegistryPath); + return overrides; +} + +/** + * A marketplace entry annotated with its scope + */ +export interface ScopedMarketplaceEntry extends MarketplaceEntry { + scope: MarketplaceScope; +} + +/** + * Result of listing marketplaces with scope annotations + */ +export interface ScopedMarketplaceListResult { + entries: ScopedMarketplaceEntry[]; + overrides: string[]; +} + +/** + * List marketplaces from both user and project registries with scope annotations. + * Project entries override user entries on name collision. + * Results are sorted by name. Also returns override names to avoid a second registry read. + */ +export async function listMarketplacesWithScope( + userRegistryPath: string, + projectRegistryPath: string, +): Promise { + const [userRegistry, projectRegistry] = await Promise.all([ + loadRegistryFromPath(userRegistryPath), + loadRegistryFromPath(projectRegistryPath), + ]); + + const projectNames = new Set(Object.keys(projectRegistry.marketplaces)); + const entries: ScopedMarketplaceEntry[] = []; + const overrides: string[] = []; + + // Add user entries that aren't overridden by project + for (const entry of Object.values(userRegistry.marketplaces)) { + if (projectNames.has(entry.name)) { + overrides.push(entry.name); + } else { + entries.push({ ...entry, scope: 'user' }); + } + } + + // Add all project entries + for (const entry of Object.values(projectRegistry.marketplaces)) { + entries.push({ ...entry, scope: 'project' }); + } + + return { + entries: entries.sort((a, b) => a.name.localeCompare(b.name)), + overrides, + }; +} + /** * Get the short git commit hash and date for a marketplace directory. * Returns null if the marketplace is not a git repo or has no commits. diff --git a/src/core/sync.ts b/src/core/sync.ts index c4295a95..c7ff5fd5 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -45,6 +45,9 @@ import { resolvePluginSpecWithAutoRegister, ensureMarketplacesRegistered, parsePluginSpec, + getMarketplaceOverrides, + getRegistryPath, + getProjectRegistryPath, } from './marketplace.js'; import { loadSyncState, @@ -829,6 +832,7 @@ async function validatePlugin( if (isPluginSpec(pluginSource)) { const resolved = await resolvePluginSpecWithAutoRegister(pluginSource, { offline, + workspacePath, }); if (!resolved.success) { return { @@ -1572,6 +1576,15 @@ export async function syncWorkspace( ); } + // Check for marketplace overrides (project shadowing user) + const overrides = await getMarketplaceOverrides( + getRegistryPath(), + getProjectRegistryPath(workspacePath), + ); + for (const name of overrides) { + console.warn(`Warning: Workspace marketplace '${name}' overrides user marketplace of the same name.`); + } + // Check if repositories are configured — when empty/absent, skip agent file // creation and WORKSPACE-RULES injection (same pattern as initWorkspace) const hasRepositories = (config.repositories?.length ?? 0) > 0; diff --git a/tests/unit/core/marketplace-scope.test.ts b/tests/unit/core/marketplace-scope.test.ts new file mode 100644 index 00000000..7650883c --- /dev/null +++ b/tests/unit/core/marketplace-scope.test.ts @@ -0,0 +1,716 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock git module before importing marketplace (needed for addMarketplace tests) +mock.module('../../../src/core/git.js', () => ({ + cloneTo: mock((url: string, dest: string) => { + mkdirSync(dest, { recursive: true }); + return Promise.resolve(); + }), + gitHubUrl: (owner: string, repo: string) => `https://github.com/${owner}/${repo}.git`, + GitCloneError: class GitCloneError extends Error { + url: string; + isTimeout: boolean; + isAuthError: boolean; + constructor(message: string, url: string, isTimeout = false, isAuthError = false) { + super(message); + this.url = url; + this.isTimeout = isTimeout; + this.isAuthError = isAuthError; + } + }, + pull: mock(() => Promise.resolve()), +})); + +mock.module('simple-git', () => ({ + default: () => ({ + raw: mock(() => Promise.resolve('')), + checkout: mock(() => Promise.resolve()), + }), +})); + +import { + loadRegistryFromPath, + saveRegistryToPath, + getProjectRegistryPath, + loadMergedRegistries, + listMarketplacesWithScope, + addMarketplace, + removeMarketplace, + getRegistryPath, + getMarketplace, + findMarketplace, + getMarketplaceOverrides, +} from '../../../src/core/marketplace.js'; +import type { MarketplaceRegistry } from '../../../src/core/marketplace.js'; + +describe('scope-aware registry loading and saving', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(tmpdir(), `marketplace-scope-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('getProjectRegistryPath', () => { + it('returns correct path under .allagents', () => { + const result = getProjectRegistryPath('/some/workspace'); + expect(result).toBe(join('/some/workspace', '.allagents', 'marketplaces.json')); + }); + }); + + describe('loadRegistryFromPath', () => { + it('loads valid registry from file', async () => { + const registryPath = join(tmpDir, 'marketplaces.json'); + const registry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'test-marketplace': { + name: 'test-marketplace', + source: { type: 'github', location: 'owner/repo' }, + path: '/some/path', + lastUpdated: '2026-01-01T00:00:00.000Z', + }, + }, + }; + writeFileSync(registryPath, JSON.stringify(registry, null, 2)); + + const loaded = await loadRegistryFromPath(registryPath); + expect(loaded).toEqual(registry); + }); + + it('returns empty registry for nonexistent path', async () => { + const loaded = await loadRegistryFromPath(join(tmpDir, 'nonexistent.json')); + expect(loaded).toEqual({ version: 1, marketplaces: {} }); + }); + + it('returns empty registry for invalid JSON', async () => { + const registryPath = join(tmpDir, 'bad.json'); + writeFileSync(registryPath, 'not valid json {{{'); + + const loaded = await loadRegistryFromPath(registryPath); + expect(loaded).toEqual({ version: 1, marketplaces: {} }); + }); + }); + + describe('saveRegistryToPath', () => { + it('writes registry to specified path and creates parent dirs', async () => { + const nestedPath = join(tmpDir, 'deep', 'nested', 'dir', 'marketplaces.json'); + const registry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'my-marketplace': { + name: 'my-marketplace', + source: { type: 'local', location: '/local/path' }, + path: '/local/path', + }, + }, + }; + + await saveRegistryToPath(registry, nestedPath); + + expect(existsSync(nestedPath)).toBe(true); + const content = readFileSync(nestedPath, 'utf-8'); + expect(JSON.parse(content)).toEqual(registry); + // Verify trailing newline + expect(content.endsWith('\n')).toBe(true); + }); + }); + + describe('loadMergedRegistries', () => { + it('merges user and project registries with project taking precedence', async () => { + const userPath = join(tmpDir, 'user-marketplaces.json'); + const projectPath = join(tmpDir, 'project-marketplaces.json'); + + const userRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'github', location: 'user-org/shared' }, + path: '/user/shared', + }, + 'user-only': { + name: 'user-only', + source: { type: 'github', location: 'user-org/user-only' }, + path: '/user/user-only', + }, + }, + }; + + const projectRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'github', location: 'project-org/shared' }, + path: '/project/shared', + }, + 'project-only': { + name: 'project-only', + source: { type: 'local', location: '/project/project-only' }, + path: '/project/project-only', + }, + }, + }; + + writeFileSync(userPath, JSON.stringify(userRegistry)); + writeFileSync(projectPath, JSON.stringify(projectRegistry)); + + const result = await loadMergedRegistries(userPath, projectPath); + + // Project wins on shared name + expect(result.registry.marketplaces['shared'].source.location).toBe('project-org/shared'); + // Both unique entries present + expect(result.registry.marketplaces['user-only']).toBeDefined(); + expect(result.registry.marketplaces['project-only']).toBeDefined(); + // Overrides list correct + expect(result.overrides).toEqual(['shared']); + }); + + it('works when project registry does not exist', async () => { + const userPath = join(tmpDir, 'user-marketplaces.json'); + const projectPath = join(tmpDir, 'nonexistent.json'); + + const userRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'user-mp': { + name: 'user-mp', + source: { type: 'github', location: 'org/repo' }, + path: '/user/mp', + }, + }, + }; + writeFileSync(userPath, JSON.stringify(userRegistry)); + + const result = await loadMergedRegistries(userPath, projectPath); + + expect(result.registry.marketplaces['user-mp']).toBeDefined(); + expect(result.overrides).toEqual([]); + }); + + it('works when user registry does not exist', async () => { + const userPath = join(tmpDir, 'nonexistent.json'); + const projectPath = join(tmpDir, 'project-marketplaces.json'); + + const projectRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'project-mp': { + name: 'project-mp', + source: { type: 'local', location: '/local/path' }, + path: '/local/path', + }, + }, + }; + writeFileSync(projectPath, JSON.stringify(projectRegistry)); + + const result = await loadMergedRegistries(userPath, projectPath); + + expect(result.registry.marketplaces['project-mp']).toBeDefined(); + expect(Object.keys(result.registry.marketplaces)).toHaveLength(1); + }); + }); + + describe('listMarketplacesWithScope', () => { + it('lists entries with correct scope annotations', async () => { + const userPath = join(tmpDir, 'user-marketplaces.json'); + const projectPath = join(tmpDir, 'project-marketplaces.json'); + + const userRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'alpha': { + name: 'alpha', + source: { type: 'github', location: 'org/alpha' }, + path: '/user/alpha', + }, + }, + }; + + const projectRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'beta': { + name: 'beta', + source: { type: 'local', location: '/project/beta' }, + path: '/project/beta', + }, + }, + }; + + writeFileSync(userPath, JSON.stringify(userRegistry)); + writeFileSync(projectPath, JSON.stringify(projectRegistry)); + + const result = await listMarketplacesWithScope(userPath, projectPath); + + expect(result.entries).toHaveLength(2); + expect(result.entries[0].name).toBe('alpha'); + expect(result.entries[0].scope).toBe('user'); + expect(result.entries[1].name).toBe('beta'); + expect(result.entries[1].scope).toBe('project'); + expect(result.overrides).toEqual([]); + }); + + it('overridden entries show as project scope with project values', async () => { + const userPath = join(tmpDir, 'user-marketplaces.json'); + const projectPath = join(tmpDir, 'project-marketplaces.json'); + + const userRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'github', location: 'user-org/shared' }, + path: '/user/shared', + }, + }, + }; + + const projectRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'local', location: '/project/shared' }, + path: '/project/shared', + }, + }, + }; + + writeFileSync(userPath, JSON.stringify(userRegistry)); + writeFileSync(projectPath, JSON.stringify(projectRegistry)); + + const result = await listMarketplacesWithScope(userPath, projectPath); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].name).toBe('shared'); + expect(result.entries[0].scope).toBe('project'); + expect(result.entries[0].source.location).toBe('/project/shared'); + expect(result.overrides).toEqual(['shared']); + }); + }); +}); + +describe('addMarketplace with scope', () => { + let originalHome: string | undefined; + let testHome: string; + let tmpProject: string; + + beforeEach(() => { + originalHome = process.env.HOME; + testHome = join(tmpdir(), `marketplace-scope-add-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + process.env.HOME = testHome; + mkdirSync(join(testHome, '.allagents'), { recursive: true }); + + tmpProject = join(tmpdir(), `marketplace-scope-project-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(tmpProject, '.allagents'), { recursive: true }); + }); + + afterEach(() => { + process.env.HOME = originalHome; + rmSync(testHome, { recursive: true, force: true }); + rmSync(tmpProject, { recursive: true, force: true }); + }); + + it('should add local marketplace to project scope', async () => { + // Create a local marketplace directory + const localMarketplace = join(tmpProject, 'my-local-marketplace'); + mkdirSync(localMarketplace, { recursive: true }); + + const result = await addMarketplace(localMarketplace, undefined, undefined, { + scope: 'project', + workspacePath: tmpProject, + }); + + expect(result.success).toBe(true); + expect(result.marketplace?.name).toBe('my-local-marketplace'); + + // Verify project registry was written + const projectRegistryPath = getProjectRegistryPath(tmpProject); + expect(existsSync(projectRegistryPath)).toBe(true); + const projectRegistry = JSON.parse(readFileSync(projectRegistryPath, 'utf-8')); + expect(projectRegistry.marketplaces['my-local-marketplace']).toBeDefined(); + + // Verify user registry was NOT written to + const userRegistryPath = getRegistryPath(); + expect(existsSync(userRegistryPath)).toBe(false); + }); + + it('should default to user scope when no scope provided', async () => { + // Create a local marketplace directory + const localMarketplace = join(testHome, 'default-scope-marketplace'); + mkdirSync(localMarketplace, { recursive: true }); + + const result = await addMarketplace(localMarketplace); + + expect(result.success).toBe(true); + expect(result.marketplace?.name).toBe('default-scope-marketplace'); + + // Verify user registry was written + const userRegistryPath = getRegistryPath(); + expect(existsSync(userRegistryPath)).toBe(true); + const userRegistry = JSON.parse(readFileSync(userRegistryPath, 'utf-8')); + expect(userRegistry.marketplaces['default-scope-marketplace']).toBeDefined(); + }); +}); + +describe('removeMarketplace with scope', () => { + let originalHome: string | undefined; + let testHome: string; + let tmpProject: string; + let userRegistryPath: string; + let projectRegistryPath: string; + + const sharedEntry = (path: string) => ({ + name: 'shared', + source: { type: 'local' as const, location: path }, + path, + }); + + beforeEach(() => { + originalHome = process.env.HOME; + testHome = join(tmpdir(), `marketplace-scope-remove-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + process.env.HOME = testHome; + mkdirSync(join(testHome, '.allagents'), { recursive: true }); + + tmpProject = join(tmpdir(), `marketplace-scope-remove-project-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(tmpProject, '.allagents'), { recursive: true }); + + userRegistryPath = getRegistryPath(); + projectRegistryPath = getProjectRegistryPath(tmpProject); + }); + + afterEach(() => { + process.env.HOME = originalHome; + rmSync(testHome, { recursive: true, force: true }); + rmSync(tmpProject, { recursive: true, force: true }); + }); + + it('should remove only from project scope when scope is project', async () => { + const userPath = join(testHome, 'shared-user'); + const projectPath = join(tmpProject, 'shared-project'); + mkdirSync(userPath, { recursive: true }); + mkdirSync(projectPath, { recursive: true }); + + // Set up both registries with 'shared' + await saveRegistryToPath({ version: 1, marketplaces: { shared: sharedEntry(userPath) } }, userRegistryPath); + await saveRegistryToPath({ version: 1, marketplaces: { shared: sharedEntry(projectPath) } }, projectRegistryPath); + + const result = await removeMarketplace('shared', { + scope: 'project', + workspacePath: tmpProject, + userRegistryPath, + }); + + expect(result.success).toBe(true); + + // Project registry should be empty + const projReg = await loadRegistryFromPath(projectRegistryPath); + expect(projReg.marketplaces['shared']).toBeUndefined(); + + // User registry should still have 'shared' + const userReg = await loadRegistryFromPath(userRegistryPath); + expect(userReg.marketplaces['shared']).toBeDefined(); + }); + + it('should remove only from user scope when scope is user', async () => { + const userPath = join(testHome, 'shared-user'); + const projectPath = join(tmpProject, 'shared-project'); + mkdirSync(userPath, { recursive: true }); + mkdirSync(projectPath, { recursive: true }); + + await saveRegistryToPath({ version: 1, marketplaces: { shared: sharedEntry(userPath) } }, userRegistryPath); + await saveRegistryToPath({ version: 1, marketplaces: { shared: sharedEntry(projectPath) } }, projectRegistryPath); + + const result = await removeMarketplace('shared', { + scope: 'user', + workspacePath: tmpProject, + userRegistryPath, + }); + + expect(result.success).toBe(true); + + // User registry should be empty + const userReg = await loadRegistryFromPath(userRegistryPath); + expect(userReg.marketplaces['shared']).toBeUndefined(); + + // Project registry should still have 'shared' + const projReg = await loadRegistryFromPath(projectRegistryPath); + expect(projReg.marketplaces['shared']).toBeDefined(); + }); + + it('should remove from both scopes when scope is all', async () => { + const userPath = join(testHome, 'shared-user'); + const projectPath = join(tmpProject, 'shared-project'); + mkdirSync(userPath, { recursive: true }); + mkdirSync(projectPath, { recursive: true }); + + await saveRegistryToPath({ version: 1, marketplaces: { shared: sharedEntry(userPath) } }, userRegistryPath); + await saveRegistryToPath({ version: 1, marketplaces: { shared: sharedEntry(projectPath) } }, projectRegistryPath); + + const result = await removeMarketplace('shared', { + scope: 'all', + workspacePath: tmpProject, + userRegistryPath, + }); + + expect(result.success).toBe(true); + + // Both registries should be empty + const userReg = await loadRegistryFromPath(userRegistryPath); + expect(userReg.marketplaces['shared']).toBeUndefined(); + + const projReg = await loadRegistryFromPath(projectRegistryPath); + expect(projReg.marketplaces['shared']).toBeUndefined(); + }); + + it('should return error when marketplace not found in any scope', async () => { + const result = await removeMarketplace('nonexistent', { + scope: 'all', + workspacePath: tmpProject, + userRegistryPath, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); +}); + +describe('runtime resolution with merged registries', () => { + let originalHome: string | undefined; + let testHome: string; + let tmpProject: string; + + beforeEach(() => { + originalHome = process.env.HOME; + testHome = join(tmpdir(), `marketplace-resolve-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + process.env.HOME = testHome; + mkdirSync(join(testHome, '.allagents'), { recursive: true }); + + tmpProject = join(tmpdir(), `marketplace-resolve-project-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(tmpProject, '.allagents'), { recursive: true }); + }); + + afterEach(() => { + process.env.HOME = originalHome; + rmSync(testHome, { recursive: true, force: true }); + rmSync(tmpProject, { recursive: true, force: true }); + }); + + it('should find marketplace from project registry via getMarketplace', async () => { + const projectRegistryPath = getProjectRegistryPath(tmpProject); + const projectRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'project-mp': { + name: 'project-mp', + source: { type: 'local', location: '/project/mp' }, + path: '/project/mp', + }, + }, + }; + writeFileSync(projectRegistryPath, JSON.stringify(projectRegistry)); + + const result = await getMarketplace('project-mp', tmpProject); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('project-mp'); + expect(result!.path).toBe('/project/mp'); + }); + + it('should return null from getMarketplace when not in any registry', async () => { + const result = await getMarketplace('nonexistent', tmpProject); + expect(result).toBeNull(); + }); + + it('should prefer project entry via getMarketplace when both registries have same name', async () => { + // Set up user registry + const userRegistryPath = getRegistryPath(); + const userRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'github', location: 'user-org/shared' }, + path: '/user/shared', + }, + }, + }; + writeFileSync(userRegistryPath, JSON.stringify(userRegistry)); + + // Set up project registry with same name but different path + const projectRegistryPath = getProjectRegistryPath(tmpProject); + const projectRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'local', location: '/project/shared' }, + path: '/project/shared', + }, + }, + }; + writeFileSync(projectRegistryPath, JSON.stringify(projectRegistry)); + + const result = await getMarketplace('shared', tmpProject); + + expect(result).not.toBeNull(); + expect(result!.path).toBe('/project/shared'); + expect(result!.source.location).toBe('/project/shared'); + }); + + it('should prefer project entry via findMarketplace', async () => { + // Set up user registry + const userRegistryPath = getRegistryPath(); + const userRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'github', location: 'user-org/shared' }, + path: '/user/shared', + }, + }, + }; + writeFileSync(userRegistryPath, JSON.stringify(userRegistry)); + + // Set up project registry with same name + const projectRegistryPath = getProjectRegistryPath(tmpProject); + const projectRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'local', location: '/project/shared' }, + path: '/project/shared', + }, + }, + }; + writeFileSync(projectRegistryPath, JSON.stringify(projectRegistry)); + + const result = await findMarketplace('shared', undefined, tmpProject); + + expect(result).not.toBeNull(); + expect(result!.path).toBe('/project/shared'); + }); + + it('should fall back to user registry via getMarketplace when not in project', async () => { + // Set up user registry only + const userRegistryPath = getRegistryPath(); + const userRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'user-only': { + name: 'user-only', + source: { type: 'github', location: 'org/user-only' }, + path: '/user/user-only', + }, + }, + }; + writeFileSync(userRegistryPath, JSON.stringify(userRegistry)); + + const result = await getMarketplace('user-only', tmpProject); + + expect(result).not.toBeNull(); + expect(result!.name).toBe('user-only'); + }); +}); + +describe('getMarketplaceOverrides', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = join(tmpdir(), `marketplace-overrides-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tmpDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return override names when project overrides user', async () => { + const userPath = join(tmpDir, 'user-marketplaces.json'); + const projectPath = join(tmpDir, 'project-marketplaces.json'); + + const userRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'github', location: 'user-org/shared' }, + path: '/user/shared', + }, + 'user-only': { + name: 'user-only', + source: { type: 'github', location: 'user-org/user-only' }, + path: '/user/user-only', + }, + }, + }; + + const projectRegistry: MarketplaceRegistry = { + version: 1, + marketplaces: { + 'shared': { + name: 'shared', + source: { type: 'local', location: '/project/shared' }, + path: '/project/shared', + }, + }, + }; + + writeFileSync(userPath, JSON.stringify(userRegistry)); + writeFileSync(projectPath, JSON.stringify(projectRegistry)); + + const overrides = await getMarketplaceOverrides(userPath, projectPath); + expect(overrides).toEqual(['shared']); + }); + + it('should return empty when no project registry exists', async () => { + const userPath = join(tmpDir, 'user-marketplaces.json'); + const projectPath = join(tmpDir, 'nonexistent.json'); + + writeFileSync(userPath, JSON.stringify({ version: 1, marketplaces: {} })); + + const overrides = await getMarketplaceOverrides(userPath, projectPath); + expect(overrides).toEqual([]); + }); + + it('should return empty when no overlapping names', async () => { + const userPath = join(tmpDir, 'user-marketplaces.json'); + const projectPath = join(tmpDir, 'project-marketplaces.json'); + + writeFileSync(userPath, JSON.stringify({ + version: 1, + marketplaces: { + 'user-mp': { + name: 'user-mp', + source: { type: 'github', location: 'org/user-mp' }, + path: '/user/mp', + }, + }, + })); + + writeFileSync(projectPath, JSON.stringify({ + version: 1, + marketplaces: { + 'project-mp': { + name: 'project-mp', + source: { type: 'local', location: '/project/mp' }, + path: '/project/mp', + }, + }, + })); + + const overrides = await getMarketplaceOverrides(userPath, projectPath); + expect(overrides).toEqual([]); + }); +});