-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathplugin.ts
More file actions
530 lines (475 loc) · 15.2 KB
/
Copy pathplugin.ts
File metadata and controls
530 lines (475 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
import { mkdir, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import simpleGit from 'simple-git';
import {
parseGitHubUrl,
getPluginCachePath,
validatePluginSource,
} from '../utils/plugin-path.js';
import { getHomeDir } from '../constants.js';
import { cloneTo, gitHubUrl, GitCloneError, pull } from './git.js';
/**
* Information about a cached plugin
*/
export interface CachedPlugin {
name: string;
path: string;
lastModified: Date;
}
/**
* Result of plugin fetch operation
*/
export interface FetchResult {
success: boolean;
action: 'fetched' | 'updated' | 'skipped';
cachePath: string;
error?: string;
/** Duration of the git operation in milliseconds */
durationMs?: number;
/** The ref (branch/tag) the fetch resolved against, if known. */
resolvedRef?: string;
/** Resolved commit SHA of the cached working tree, if known. */
resolvedSha?: string;
}
/**
* Options for fetchPlugin
*/
export interface FetchOptions {
/** Skip fetching from remote and use cached version if available */
offline?: boolean;
/** Branch to checkout after fetching (defaults to default branch) */
branch?: string;
}
/**
* Dependencies for fetchPlugin (for testing)
*/
export interface FetchDeps {
existsSync?: typeof existsSync;
mkdir?: typeof mkdir;
cloneTo?: typeof cloneTo;
pull?: typeof pull;
}
/**
* Resolve the HEAD commit SHA of a local repository. Returns undefined if the
* directory isn't a git repo (e.g., a marketplace subdirectory that was
* copied rather than cloned) or rev-parse fails for any other reason.
*/
async function resolveHeadSha(repoPath: string): Promise<string | undefined> {
try {
const sha = await simpleGit(repoPath).revparse(['HEAD']);
const trimmed = sha.trim();
return trimmed.length > 0 ? trimmed : undefined;
} catch {
return undefined;
}
}
// Deduplicates git operations for the same cache directory within a sync session.
// Both concurrent and sequential callers targeting the same repo reuse the
// result of the first git operation. Call `resetFetchCache()` between sync
// sessions to allow fresh fetches.
const fetchCache = new Map<string, Promise<FetchResult>>();
/**
* Reset the fetch cache between sync sessions.
* Call this at the start of a sync to ensure fresh fetches.
*/
export function resetFetchCache(): void {
fetchCache.clear();
}
/**
* Seed the fetch cache with a pre-resolved path for a GitHub URL.
*
* Call this after marketplace registration/update so that subsequent
* `fetchPlugin` calls for the same repo skip the redundant git pull.
*
* @param url - GitHub URL or owner/repo shorthand
* @param path - Local path where the repo already exists
* @param branch - Optional branch override (seeds branch-qualified cache key)
*/
export function seedFetchCache(url: string, path: string, branch?: string): void {
const parsed = parseGitHubUrl(url);
if (!parsed) return;
const { owner, repo } = parsed;
const cachePath = getPluginCachePath(owner, repo, branch ?? parsed.branch);
// Don't overwrite if already populated (e.g. by a prior fetchPlugin call)
if (fetchCache.has(cachePath)) return;
fetchCache.set(
cachePath,
Promise.resolve({
success: true,
action: 'skipped' as const,
cachePath: path,
}),
);
}
/**
* Fetch a plugin from GitHub to local cache.
*
* Deduplicates git operations: the first caller for a given cache path
* performs the git pull/clone; all subsequent callers (concurrent or
* sequential) reuse the same result within the current sync session.
*
* @param url - GitHub URL of the plugin
* @param options - Fetch options (force update)
* @param deps - Optional dependencies for testing
* @returns Result of the fetch operation
*/
export async function fetchPlugin(
url: string,
options: FetchOptions = {},
deps: FetchDeps = {},
): Promise<FetchResult> {
const { offline = false, branch } = options;
// Validate plugin source
const validation = validatePluginSource(url);
if (!validation.valid) {
return {
success: false,
action: 'skipped',
cachePath: '',
...(validation.error && { error: validation.error }),
};
}
// Parse GitHub URL
const parsed = parseGitHubUrl(url);
if (!parsed) {
return {
success: false,
action: 'skipped',
cachePath: '',
error:
'Invalid GitHub URL format. Expected: https://github.com/owner/repo',
};
}
const { owner, repo } = parsed;
const cachePath = getPluginCachePath(owner, repo, branch);
// Return cached result if this repo was already fetched this session
const cached = fetchCache.get(cachePath);
if (cached) {
return cached;
}
const promise = doFetchPlugin(
cachePath,
owner,
repo,
offline,
branch,
deps,
);
fetchCache.set(cachePath, promise);
return promise;
}
/**
* Internal: performs the actual git fetch/pull/clone for a plugin.
*/
async function doFetchPlugin(
cachePath: string,
owner: string,
repo: string,
offline: boolean,
branch: string | undefined,
deps: FetchDeps,
): Promise<FetchResult> {
const {
existsSync: existsSyncFn = existsSync,
mkdir: mkdirFn = mkdir,
cloneTo: cloneToFn = cloneTo,
pull: pullFn = pull,
} = deps;
// Check if plugin is already cached
const isCached = existsSyncFn(cachePath);
if (isCached && offline) {
// Offline mode: use cached version without fetching
return {
success: true,
action: 'skipped',
cachePath,
};
}
const repoUrl = gitHubUrl(owner, repo);
if (isCached) {
// Pull latest changes, but treat failures as non-fatal since the
// cached version is still usable (e.g. concurrent pulls on the same
// shallow clone can fail with "not something we can merge").
try {
const pullStart = performance.now();
await pullFn(cachePath);
const pullMs = Math.round(performance.now() - pullStart);
const sha = await resolveHeadSha(cachePath);
return {
success: true,
action: 'updated',
cachePath,
durationMs: pullMs,
...(branch && { resolvedRef: branch }),
...(sha && { resolvedSha: sha }),
};
} catch {
const sha = await resolveHeadSha(cachePath);
return {
success: true,
action: 'skipped',
cachePath,
...(branch && { resolvedRef: branch }),
...(sha && { resolvedSha: sha }),
};
}
}
try {
// Clone new plugin
// Ensure parent directory exists
const parentDir = dirname(cachePath);
await mkdirFn(parentDir, { recursive: true });
const cloneStart = performance.now();
await cloneToFn(repoUrl, cachePath, branch);
const cloneMs = Math.round(performance.now() - cloneStart);
const sha = await resolveHeadSha(cachePath);
return {
success: true,
action: 'fetched',
cachePath,
durationMs: cloneMs,
...(branch && { resolvedRef: branch }),
...(sha && { resolvedSha: sha }),
};
} catch (error) {
if (error instanceof GitCloneError) {
if (error.isAuthError) {
return {
success: false,
action: 'skipped',
cachePath,
error: `Authentication failed for ${owner}/${repo}.\n Check your SSH keys or git credentials.`,
};
}
if (error.isTimeout) {
return {
success: false,
action: 'skipped',
cachePath,
error: `Clone timed out for ${owner}/${repo}.\n Check your network connection.`,
};
}
}
return {
success: false,
action: 'skipped',
cachePath,
error: `Failed to fetch plugin: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Get the cache directory for plugins
* @returns Path to plugin cache directory
*/
export function getPluginCacheDir(): string {
return resolve(getHomeDir(), '.allagents', 'plugins', 'marketplaces');
}
/**
* List all cached plugins
* @returns Array of cached plugin information
*/
export async function listCachedPlugins(): Promise<CachedPlugin[]> {
const cacheDir = getPluginCacheDir();
if (!existsSync(cacheDir)) {
return [];
}
const entries = await readdir(cacheDir, { withFileTypes: true });
const plugins: CachedPlugin[] = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const pluginPath = join(cacheDir, entry.name);
const stats = await stat(pluginPath);
plugins.push({
name: entry.name,
path: pluginPath,
lastModified: stats.mtime,
});
}
}
// Sort by name
plugins.sort((a, b) => a.name.localeCompare(b.name));
return plugins;
}
/**
* Result of update operation
*/
export interface UpdateResult {
name: string;
success: boolean;
error?: string;
}
/**
* Update cached plugins by running git pull
* @param name - Optional plugin name to update (updates all if not specified)
* @returns Array of update results
*/
export async function updateCachedPlugins(
name?: string,
): Promise<UpdateResult[]> {
const plugins = await listCachedPlugins();
const results: UpdateResult[] = [];
// Filter by name if specified
const toUpdate = name ? plugins.filter((p) => p.name === name) : plugins;
if (name && toUpdate.length === 0) {
return [
{
name,
success: false,
error: `Plugin not found in cache: ${name}`,
},
];
}
for (const plugin of toUpdate) {
try {
await pull(plugin.path);
results.push({ name: plugin.name, success: true });
} catch (error) {
results.push({
name: plugin.name,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
return results;
}
/**
* Get the plugin name from the directory name.
*
* Cache directories for branch/tag-pinned clones use the form
* `<owner>-<repo>@<sanitized-ref>`. The pin suffix is part of the on-disk
* layout for collision avoidance, but the logical plugin name is the
* base — strip the suffix so callers that key workspace.yaml entries by
* plugin name (e.g., setPluginSkillsMode) match against the unpinned form.
*
* @param pluginPath - Resolved path to the plugin directory
* @returns The plugin name (directory basename, without any `@ref` suffix)
*/
export function getPluginName(pluginPath: string): string {
const base = basename(pluginPath);
const atIdx = base.indexOf('@');
return atIdx === -1 ? base : base.slice(0, atIdx);
}
/**
* Result of updating an installed plugin
*/
export interface InstalledPluginUpdateResult {
plugin: string;
success: boolean;
action: 'updated' | 'skipped' | 'failed';
error?: string;
}
/**
* Dependencies for updatePlugin (for testing)
*/
export interface UpdatePluginDeps {
parsePluginSpec: (spec: string) => { plugin: string; marketplaceName: string; owner?: string; repo?: string } | null;
getMarketplace: (name: string, sourceLocation?: string) => Promise<{ name: string; path: string; source: { type: string } } | null>;
parseMarketplaceManifest: (path: string) => Promise<{ success: boolean; data?: { plugins: Array<{ name: string; source: string | { url: string } }> } }>;
updateMarketplace: (name: string) => Promise<Array<{ name: string; success: boolean; error?: string }>>;
/** Optional fetch function for testing - defaults to fetchPlugin */
fetchFn?: (url: string) => Promise<FetchResult>;
}
/**
* Update a single plugin by pulling from remote.
* Handles both marketplace-embedded and external plugins.
*
* @param pluginSpec - Plugin spec (e.g., "plugin@marketplace" or GitHub URL)
* @param deps - Dependencies for marketplace operations (lazy loaded to avoid circular imports)
*/
export async function updatePlugin(
pluginSpec: string,
deps: UpdatePluginDeps,
): Promise<InstalledPluginUpdateResult> {
const fetchFn = deps.fetchFn ?? fetchPlugin;
// Handle plugin@marketplace format
const parsed = deps.parsePluginSpec(pluginSpec);
if (!parsed) {
// Might be a GitHub URL or local path
if (pluginSpec.startsWith('https://github.com/')) {
// External GitHub URL - update the cached repo
const result = await fetchFn(pluginSpec);
return {
plugin: pluginSpec,
success: result.success,
action: result.action === 'updated' ? 'updated' : result.success ? 'skipped' : 'failed',
...(result.error && { error: result.error }),
};
}
// Local path - nothing to update
return {
plugin: pluginSpec,
success: true,
action: 'skipped',
};
}
// Get marketplace info (with source location fallback for owner/repo format)
const sourceLocation = parsed.owner && parsed.repo ? `${parsed.owner}/${parsed.repo}` : undefined;
const marketplace = await deps.getMarketplace(parsed.marketplaceName, sourceLocation);
if (!marketplace) {
return {
plugin: pluginSpec,
success: false,
action: 'failed',
error: `Marketplace not found: ${parsed.marketplaceName}`,
};
}
// Use the actual marketplace name (may differ from parsed name if found by source location)
const marketplaceName = marketplace.name;
// Parse marketplace manifest to determine if plugin is embedded or external
const manifestResult = await deps.parseMarketplaceManifest(marketplace.path);
if (!manifestResult.success || !manifestResult.data) {
// No manifest - update the marketplace itself (plugin might be in directory)
const updateResults = await deps.updateMarketplace(marketplaceName);
const result = updateResults[0];
return {
plugin: pluginSpec,
success: result?.success ?? false,
action: result?.success ? 'updated' : 'failed',
...(result?.error && { error: result.error }),
};
}
// Find plugin entry in manifest
const pluginEntry = manifestResult.data.plugins.find(
(p) => p.name === parsed.plugin,
);
if (!pluginEntry) {
// Plugin not in manifest - update marketplace and hope for the best
const updateResults = await deps.updateMarketplace(marketplaceName);
const result = updateResults[0];
return {
plugin: pluginSpec,
success: result?.success ?? false,
action: result?.success ? 'updated' : 'failed',
...(result?.error && { error: result.error }),
};
}
// Check if embedded (string path) or external (url object)
if (typeof pluginEntry.source === 'string') {
// Embedded plugin - update the marketplace
const updateResults = await deps.updateMarketplace(marketplaceName);
const result = updateResults[0];
return {
plugin: pluginSpec,
success: result?.success ?? false,
action: result?.success ? 'updated' : 'failed',
...(result?.error && { error: result.error }),
};
}
// External plugin - update both marketplace (for manifest changes) and the cached repo
const url = pluginEntry.source.url;
// Update the marketplace first (in case manifest changed)
if (marketplace.source.type === 'github') {
await deps.updateMarketplace(marketplaceName);
}
// Update the external plugin cache
const fetchResult = await fetchFn(url);
return {
plugin: pluginSpec,
success: fetchResult.success,
action: fetchResult.action === 'updated' ? 'updated' : fetchResult.success ? 'skipped' : 'failed',
...(fetchResult.error && { error: fetchResult.error }),
};
}