Skip to content

Commit 83718c2

Browse files
authored
fix: pull latest marketplace before resolving plugins (#176)
* fix: pull latest marketplace before resolving plugins When resolving a plugin@marketplace spec, call updateMarketplace() to git pull latest changes before looking up the plugin. Uses a session-level cache (Set) to ensure each marketplace is only pulled once per CLI session, avoiding slowness when multiple plugins share the same marketplace. Closes #175 * fix: handle update failure and skip pull after fresh clone - Don't cache marketplace as updated when git pull fails, allowing retry on subsequent calls in the same session - Skip redundant pull when marketplace was just freshly auto-registered (cloned), since it already has latest data - Add tests for both failure retry and auto-register skip paths
1 parent 411e70d commit 83718c2

2 files changed

Lines changed: 318 additions & 0 deletions

File tree

src/core/marketplace.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -941,6 +941,24 @@ export async function resolvePluginSpecWithAutoRegister(
941941
};
942942
}
943943

944+
// Pull latest marketplace if online, not freshly cloned, and not yet updated this session
945+
if (
946+
!didAutoRegister &&
947+
!options.offline &&
948+
marketplace.source.type === 'github' &&
949+
!updatedMarketplaceCache.has(marketplace.name)
950+
) {
951+
const results = await updateMarketplace(marketplace.name);
952+
const result = results[0];
953+
if (result?.success) {
954+
updatedMarketplaceCache.add(marketplace.name);
955+
}
956+
}
957+
// Mark freshly cloned marketplaces as updated so subsequent calls skip the pull
958+
if (didAutoRegister) {
959+
updatedMarketplaceCache.add(marketplace.name);
960+
}
961+
944962
// Determine the expected subpath for error messages
945963
const expectedSubpath = subpath ?? 'plugins';
946964

@@ -1000,6 +1018,17 @@ export function resetAutoRegisterCache(): void {
10001018
registeredSourceCache.clear();
10011019
}
10021020

1021+
/**
1022+
* In-memory cache of marketplaces already updated (git pull) in this process.
1023+
* Ensures each marketplace is only pulled once per CLI session.
1024+
*/
1025+
const updatedMarketplaceCache = new Set<string>();
1026+
1027+
/** Reset the updated-marketplace cache (for testing only). */
1028+
export function resetUpdatedMarketplaceCache(): void {
1029+
updatedMarketplaceCache.clear();
1030+
}
1031+
10031032
/**
10041033
* Auto-register a marketplace by source.
10051034
* Only supports owner/repo format for GitHub marketplaces.
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
2+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
6+
// Track calls
7+
const pullCalls: Array<{ path: string }> = [];
8+
const simpleGitCalls: Array<{ method: string; args: unknown[] }> = [];
9+
let pullShouldFail = false;
10+
11+
function createMockGit() {
12+
return {
13+
raw: mock((...args: unknown[]) => {
14+
simpleGitCalls.push({ method: 'raw', args });
15+
const rawArgs = args[0] as string[];
16+
if (rawArgs?.[0] === 'symbolic-ref') {
17+
return Promise.resolve('origin/main');
18+
}
19+
return Promise.resolve('');
20+
}),
21+
checkout: mock((...args: unknown[]) => {
22+
simpleGitCalls.push({ method: 'checkout', args });
23+
return Promise.resolve();
24+
}),
25+
log: mock(() => Promise.resolve({ latest: { hash: 'abc1234' } })),
26+
};
27+
}
28+
29+
mock.module('simple-git', () => ({
30+
default: () => createMockGit(),
31+
}));
32+
33+
mock.module('../../../src/core/git.js', () => ({
34+
pull: mock((path: string) => {
35+
pullCalls.push({ path });
36+
if (pullShouldFail) return Promise.reject(new Error('network timeout'));
37+
return Promise.resolve();
38+
}),
39+
cloneTo: mock((_url: string, path: string) => {
40+
mkdirSync(path, { recursive: true });
41+
return Promise.resolve();
42+
}),
43+
cloneToTemp: mock(() => Promise.resolve('/tmp/fake')),
44+
gitHubUrl: (owner: string, repo: string) =>
45+
`https://github.com/${owner}/${repo}.git`,
46+
GitCloneError: class extends Error {},
47+
repoExists: mock(() => Promise.resolve(true)),
48+
refExists: mock(() => Promise.resolve(true)),
49+
cleanupTempDir: mock(() => Promise.resolve()),
50+
}));
51+
52+
const {
53+
resolvePluginSpecWithAutoRegister,
54+
resetUpdatedMarketplaceCache,
55+
} = await import('../../../src/core/marketplace.js');
56+
57+
describe('resolvePluginSpecWithAutoRegister auto-updates marketplace', () => {
58+
let originalHome: string | undefined;
59+
let testHome: string;
60+
61+
beforeEach(() => {
62+
originalHome = process.env.HOME;
63+
testHome = join(tmpdir(), `marketplace-auto-update-test-${Date.now()}`);
64+
process.env.HOME = testHome;
65+
pullCalls.length = 0;
66+
simpleGitCalls.length = 0;
67+
pullShouldFail = false;
68+
resetUpdatedMarketplaceCache();
69+
});
70+
71+
afterEach(() => {
72+
process.env.HOME = originalHome;
73+
rmSync(testHome, { recursive: true, force: true });
74+
});
75+
76+
function setupRegistry(marketplaces: Record<string, unknown>) {
77+
const registryDir = join(testHome, '.allagents');
78+
mkdirSync(registryDir, { recursive: true });
79+
writeFileSync(
80+
join(registryDir, 'marketplaces.json'),
81+
JSON.stringify({ version: 1, marketplaces }, null, 2),
82+
);
83+
}
84+
85+
function setupMarketplace(
86+
name: string,
87+
plugins: Array<{ name: string; source: string }>,
88+
) {
89+
const mpPath = join(
90+
testHome,
91+
'.allagents',
92+
'plugins',
93+
'marketplaces',
94+
name,
95+
);
96+
mkdirSync(join(mpPath, '.claude-plugin'), { recursive: true });
97+
writeFileSync(
98+
join(mpPath, '.claude-plugin', 'marketplace.json'),
99+
JSON.stringify({ name, plugins }),
100+
);
101+
for (const p of plugins) {
102+
if (p.source.startsWith('./')) {
103+
const pluginDir = join(mpPath, p.source.slice(2));
104+
mkdirSync(pluginDir, { recursive: true });
105+
}
106+
}
107+
return mpPath;
108+
}
109+
110+
it('pulls latest marketplace before resolving plugin', async () => {
111+
const mpPath = setupMarketplace('test-mp', [
112+
{ name: 'my-plugin', source: './plugins/my-plugin' },
113+
]);
114+
setupRegistry({
115+
'test-mp': {
116+
name: 'test-mp',
117+
source: { type: 'github', location: 'owner/test-mp' },
118+
path: mpPath,
119+
lastUpdated: '2024-01-01T00:00:00.000Z',
120+
},
121+
});
122+
123+
const result = await resolvePluginSpecWithAutoRegister('my-plugin@test-mp');
124+
125+
expect(result.success).toBe(true);
126+
// The key assertion: git pull was called on the marketplace
127+
expect(pullCalls.length).toBe(1);
128+
expect(pullCalls[0].path).toBe(mpPath);
129+
});
130+
131+
it('skips pull for same marketplace on second call in same session', async () => {
132+
const mpPath = setupMarketplace('test-mp', [
133+
{ name: 'plugin-a', source: './plugins/plugin-a' },
134+
{ name: 'plugin-b', source: './plugins/plugin-b' },
135+
]);
136+
setupRegistry({
137+
'test-mp': {
138+
name: 'test-mp',
139+
source: { type: 'github', location: 'owner/test-mp' },
140+
path: mpPath,
141+
lastUpdated: '2024-01-01T00:00:00.000Z',
142+
},
143+
});
144+
145+
await resolvePluginSpecWithAutoRegister('plugin-a@test-mp');
146+
pullCalls.length = 0;
147+
148+
await resolvePluginSpecWithAutoRegister('plugin-b@test-mp');
149+
150+
// Second call should NOT trigger another pull
151+
expect(pullCalls.length).toBe(0);
152+
});
153+
154+
it('skips pull when offline', async () => {
155+
const mpPath = setupMarketplace('test-mp', [
156+
{ name: 'my-plugin', source: './plugins/my-plugin' },
157+
]);
158+
setupRegistry({
159+
'test-mp': {
160+
name: 'test-mp',
161+
source: { type: 'github', location: 'owner/test-mp' },
162+
path: mpPath,
163+
lastUpdated: '2024-01-01T00:00:00.000Z',
164+
},
165+
});
166+
167+
const result = await resolvePluginSpecWithAutoRegister('my-plugin@test-mp', {
168+
offline: true,
169+
});
170+
171+
expect(result.success).toBe(true);
172+
expect(pullCalls.length).toBe(0);
173+
});
174+
175+
it('skips pull for local marketplaces', async () => {
176+
const mpPath = setupMarketplace('local-mp', [
177+
{ name: 'my-plugin', source: './plugins/my-plugin' },
178+
]);
179+
setupRegistry({
180+
'local-mp': {
181+
name: 'local-mp',
182+
source: { type: 'local', location: mpPath },
183+
path: mpPath,
184+
lastUpdated: '2024-01-01T00:00:00.000Z',
185+
},
186+
});
187+
188+
const result = await resolvePluginSpecWithAutoRegister(
189+
'my-plugin@local-mp',
190+
);
191+
192+
expect(result.success).toBe(true);
193+
expect(pullCalls.length).toBe(0);
194+
});
195+
196+
it('pulls different marketplaces independently', async () => {
197+
const mpPath1 = setupMarketplace('mp-one', [
198+
{ name: 'plugin-a', source: './plugins/plugin-a' },
199+
]);
200+
const mpPath2 = setupMarketplace('mp-two', [
201+
{ name: 'plugin-b', source: './plugins/plugin-b' },
202+
]);
203+
setupRegistry({
204+
'mp-one': {
205+
name: 'mp-one',
206+
source: { type: 'github', location: 'owner/mp-one' },
207+
path: mpPath1,
208+
lastUpdated: '2024-01-01T00:00:00.000Z',
209+
},
210+
'mp-two': {
211+
name: 'mp-two',
212+
source: { type: 'github', location: 'owner/mp-two' },
213+
path: mpPath2,
214+
lastUpdated: '2024-01-01T00:00:00.000Z',
215+
},
216+
});
217+
218+
await resolvePluginSpecWithAutoRegister('plugin-a@mp-one');
219+
await resolvePluginSpecWithAutoRegister('plugin-b@mp-two');
220+
221+
// Both marketplaces should have been pulled
222+
expect(pullCalls.length).toBe(2);
223+
expect(pullCalls[0].path).toBe(mpPath1);
224+
expect(pullCalls[1].path).toBe(mpPath2);
225+
});
226+
227+
it('retries pull on next call when previous pull failed', async () => {
228+
const mpPath = setupMarketplace('test-mp', [
229+
{ name: 'my-plugin', source: './plugins/my-plugin' },
230+
]);
231+
setupRegistry({
232+
'test-mp': {
233+
name: 'test-mp',
234+
source: { type: 'github', location: 'owner/test-mp' },
235+
path: mpPath,
236+
lastUpdated: '2024-01-01T00:00:00.000Z',
237+
},
238+
});
239+
240+
// First call: pull fails
241+
pullShouldFail = true;
242+
const result1 = await resolvePluginSpecWithAutoRegister('my-plugin@test-mp');
243+
// Plugin still resolves from cached state
244+
expect(result1.success).toBe(true);
245+
expect(pullCalls.length).toBe(1);
246+
247+
// Second call: pull should be retried (not cached as updated)
248+
pullShouldFail = false;
249+
pullCalls.length = 0;
250+
const result2 = await resolvePluginSpecWithAutoRegister('my-plugin@test-mp');
251+
expect(result2.success).toBe(true);
252+
expect(pullCalls.length).toBe(1);
253+
});
254+
255+
it('skips pull for freshly auto-registered marketplace', async () => {
256+
// No registry or marketplace pre-setup — auto-register will clone fresh
257+
const registryDir = join(testHome, '.allagents');
258+
mkdirSync(registryDir, { recursive: true });
259+
writeFileSync(
260+
join(registryDir, 'marketplaces.json'),
261+
JSON.stringify({ version: 1, marketplaces: {} }, null, 2),
262+
);
263+
264+
// The auto-register path will clone the marketplace. We need the cloned
265+
// directory to contain the plugin so resolution succeeds.
266+
const gitMod = await import('../../../src/core/git.js');
267+
const cloneToMock = gitMod.cloneTo as ReturnType<typeof mock>;
268+
cloneToMock.mockImplementation((_url: string, path: string) => {
269+
mkdirSync(join(path, '.claude-plugin'), { recursive: true });
270+
writeFileSync(
271+
join(path, '.claude-plugin', 'marketplace.json'),
272+
JSON.stringify({
273+
name: 'fresh-mp',
274+
plugins: [{ name: 'my-plugin', source: './plugins/my-plugin' }],
275+
}),
276+
);
277+
mkdirSync(join(path, 'plugins', 'my-plugin'), { recursive: true });
278+
return Promise.resolve();
279+
});
280+
281+
const result = await resolvePluginSpecWithAutoRegister(
282+
'my-plugin@owner/fresh-mp',
283+
);
284+
285+
expect(result.success).toBe(true);
286+
// No pull should have happened — marketplace was just cloned fresh
287+
expect(pullCalls.length).toBe(0);
288+
});
289+
});

0 commit comments

Comments
 (0)