claudeup 4.36.0 → 4.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/services/catalog-cache-store.ts +218 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/marketplace-catalog-git.ts +170 -0
- package/src/services/marketplace-catalog.ts +95 -0
- package/src/services/marketplace-fetcher.ts +310 -87
- package/src/services/plugin-manager.ts +103 -92
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/components/layout/ScreenLayout.tsx +19 -2
- package/src/ui/renderers/pluginRenderers.tsx +39 -1
- package/src/ui/screens/PluginsScreen.tsx +138 -29
- package/src/ui/state/reducer.ts +11 -2
- package/src/ui/state/types.ts +16 -1
- package/src/utils/config-dir.ts +47 -0
|
@@ -7,73 +7,288 @@
|
|
|
7
7
|
* imported via plugin-manager.ts).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import type { PluginRelease } from "../types/index.js";
|
|
11
10
|
import { isValidGitHubRepo } from "../utils/string-utils.js";
|
|
11
|
+
import {
|
|
12
|
+
clearCachedCatalogs,
|
|
13
|
+
readCachedCatalog,
|
|
14
|
+
writeCachedCatalog,
|
|
15
|
+
} from "./catalog-cache-store.js";
|
|
16
|
+
import {
|
|
17
|
+
cooldownFor,
|
|
18
|
+
describeCooldown,
|
|
19
|
+
hydrateGitHubBudget,
|
|
20
|
+
recordRateLimit,
|
|
21
|
+
recordSuccess,
|
|
22
|
+
type GitHubHost,
|
|
23
|
+
} from "./github-budget.js";
|
|
12
24
|
import type { LocalMarketplace } from "./local-marketplace.js";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
import {
|
|
26
|
+
clearGitCatalogCache,
|
|
27
|
+
readCatalogFromRemote,
|
|
28
|
+
} from "./marketplace-catalog-git.js";
|
|
29
|
+
import {
|
|
30
|
+
clearMarketplaceVersionCache,
|
|
31
|
+
normalizeCatalogJson,
|
|
32
|
+
type MarketplacePlugin,
|
|
33
|
+
} from "./marketplace-catalog.js";
|
|
34
|
+
|
|
35
|
+
// Re-exported: these used to live here, and plugin-manager plus several screens
|
|
36
|
+
// import them from this module.
|
|
37
|
+
export {
|
|
38
|
+
getMarketplaceVersion,
|
|
39
|
+
normalizeCatalogJson,
|
|
40
|
+
type MarketplacePlugin,
|
|
41
|
+
} from "./marketplace-catalog.js";
|
|
25
42
|
|
|
26
43
|
// Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
|
|
27
44
|
const marketplaceCache = new Map<string, MarketplacePlugin[]>();
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
/** Why a catalog fetch did not produce an authoritative plugin list. */
|
|
47
|
+
export type MarketplaceFetchFailureKind =
|
|
48
|
+
| "rate-limited"
|
|
49
|
+
| "http"
|
|
50
|
+
| "timeout"
|
|
51
|
+
| "network"
|
|
52
|
+
| "invalid-repo"
|
|
53
|
+
| "invalid-content";
|
|
54
|
+
|
|
55
|
+
export interface MarketplaceFetchFailure {
|
|
56
|
+
marketplace: string;
|
|
57
|
+
kind: MarketplaceFetchFailureKind;
|
|
58
|
+
httpStatus?: number;
|
|
59
|
+
/** Short, user-facing reason — rendered verbatim in the TUI. */
|
|
60
|
+
detail: string;
|
|
61
|
+
/**
|
|
62
|
+
* Wall-clock ms when a retry is due, for rate limits. Lets the UI count down
|
|
63
|
+
* and retry itself instead of telling the user to try again "later" and leaving
|
|
64
|
+
* them to guess how much later.
|
|
65
|
+
*/
|
|
66
|
+
retryAt?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MarketplaceFetchResult {
|
|
70
|
+
plugins: MarketplacePlugin[];
|
|
71
|
+
/** Present exactly when `plugins` is not an authoritative remote answer. */
|
|
72
|
+
failure?: MarketplaceFetchFailure;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Where a resolved plugin list actually came from.
|
|
77
|
+
*
|
|
78
|
+
* `remote` raw.githubusercontent.com — authoritative.
|
|
79
|
+
* `remote-git` the clone's `origin/<branch>` ref, refreshed by `git fetch` —
|
|
80
|
+
* also authoritative: it IS upstream's file. Reached when HTTP is
|
|
81
|
+
* rate-limited, and it draws on neither GitHub HTTP budget.
|
|
82
|
+
* `local-clone` the checked-out working tree — usable but NOT authoritative. It
|
|
83
|
+
* is deliberately pinned (`autoUpdate: false`), so its versions can
|
|
84
|
+
* be arbitrarily old.
|
|
85
|
+
* `none` nothing could be read.
|
|
86
|
+
*/
|
|
87
|
+
export type CatalogSource = "remote" | "remote-git" | "local-clone" | "none";
|
|
88
|
+
|
|
89
|
+
/** Sources whose versions may be reported to the user as current. */
|
|
90
|
+
export function isAuthoritative(source: CatalogSource): boolean {
|
|
91
|
+
return source === "remote" || source === "remote-git";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface MarketplaceResolution {
|
|
95
|
+
plugins: MarketplacePlugin[];
|
|
96
|
+
source: CatalogSource;
|
|
97
|
+
/**
|
|
98
|
+
* The remote failure, if any. Set even when `source` is `local-clone` — the
|
|
99
|
+
* fallback succeeded, but the versions in it are not known to be current.
|
|
100
|
+
*/
|
|
101
|
+
failure?: MarketplaceFetchFailure;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Failures are remembered, not just returned.
|
|
106
|
+
*
|
|
107
|
+
* Two reasons. First, so the TUI can render "4 of 6 catalogs unreachable"
|
|
108
|
+
* without every caller threading a result type through. Second, so a failure is
|
|
109
|
+
* cached like a success: an un-cached failure was re-attempted on every screen
|
|
110
|
+
* mount, and with a 10s timeout per marketplace that alone cost 30-45s of
|
|
111
|
+
* "Loading..." each time the user switched tabs and came back.
|
|
112
|
+
*
|
|
113
|
+
* The TTL is short and an explicit refresh clears it, so a transient blip never
|
|
114
|
+
* freezes the catalog for a whole session — which would be the same
|
|
115
|
+
* silent-staleness trap this module exists to close.
|
|
116
|
+
*/
|
|
117
|
+
const failureCache = new Map<string, { failure: MarketplaceFetchFailure; at: number }>();
|
|
118
|
+
const NEGATIVE_TTL_MS = 60_000;
|
|
119
|
+
|
|
120
|
+
/** Monotonic clock, so a system clock change cannot expire the cache early. */
|
|
121
|
+
function now(): number {
|
|
122
|
+
return performance.now();
|
|
123
|
+
}
|
|
34
124
|
|
|
35
125
|
export function clearMarketplaceCache(): void {
|
|
36
126
|
marketplaceCache.clear();
|
|
37
|
-
|
|
127
|
+
clearMarketplaceVersionCache();
|
|
128
|
+
clearGitCatalogCache();
|
|
129
|
+
failureCache.clear();
|
|
38
130
|
}
|
|
39
131
|
|
|
40
132
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
133
|
+
* Drop the on-disk catalog cache as well.
|
|
134
|
+
*
|
|
135
|
+
* Separate and async because it must be AWAITED before the refetch that follows
|
|
136
|
+
* it: fire-and-forget would race the next `resolveMarketplacePlugins`, which would
|
|
137
|
+
* then read the cache it was told to discard and make `r` a no-op.
|
|
138
|
+
*
|
|
139
|
+
* Deliberately does NOT clear the host cooldown. That records GitHub's state, not
|
|
140
|
+
* ours, and retrying inside a known window cannot succeed — the screen counts down
|
|
141
|
+
* and refetches by itself when the window opens.
|
|
44
142
|
*/
|
|
45
|
-
export function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
143
|
+
export async function clearPersistedCatalogs(): Promise<void> {
|
|
144
|
+
await clearCachedCatalogs();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Every marketplace whose catalog could not be fetched, freshest answer first.
|
|
149
|
+
*
|
|
150
|
+
* A caller that renders plugin versions MUST consult this. An empty list is the
|
|
151
|
+
* only proof that the versions on screen were actually checked against GitHub.
|
|
152
|
+
*/
|
|
153
|
+
export function getMarketplaceFetchFailures(): MarketplaceFetchFailure[] {
|
|
154
|
+
const out: MarketplaceFetchFailure[] = [];
|
|
155
|
+
for (const [, entry] of failureCache) {
|
|
156
|
+
if (now() - entry.at < NEGATIVE_TTL_MS) out.push(entry.failure);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Record a failure and return it as the result, so callers cannot ignore it. */
|
|
162
|
+
function fail(
|
|
163
|
+
marketplace: string,
|
|
164
|
+
kind: MarketplaceFetchFailureKind,
|
|
165
|
+
detail: string,
|
|
166
|
+
httpStatus?: number,
|
|
167
|
+
retryAt?: number,
|
|
168
|
+
): MarketplaceFetchResult {
|
|
169
|
+
const failure: MarketplaceFetchFailure = {
|
|
170
|
+
marketplace,
|
|
171
|
+
kind,
|
|
172
|
+
detail,
|
|
173
|
+
httpStatus,
|
|
174
|
+
retryAt,
|
|
175
|
+
};
|
|
176
|
+
failureCache.set(marketplace, { failure, at: now() });
|
|
177
|
+
return { plugins: [], failure };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Turn a thrown fetch error into a classified failure.
|
|
182
|
+
*
|
|
183
|
+
* A timeout is distinguished from an outright network error because they mean
|
|
184
|
+
* different things to the user: the first is "GitHub is slow or unreachable
|
|
185
|
+
* right now", the second is usually "this machine has no route at all".
|
|
186
|
+
*/
|
|
187
|
+
function classifyThrown(
|
|
188
|
+
marketplace: string,
|
|
189
|
+
error: unknown,
|
|
190
|
+
): MarketplaceFetchResult {
|
|
191
|
+
const name = (error as { name?: string } | null)?.name;
|
|
192
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
193
|
+
return fail(
|
|
194
|
+
marketplace,
|
|
195
|
+
"timeout",
|
|
196
|
+
`no response in ${FETCH_TIMEOUT_MS / 1000}s`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
200
|
+
return fail(marketplace, "network", message);
|
|
49
201
|
}
|
|
50
202
|
|
|
203
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Fetch a marketplace catalog from GitHub.
|
|
207
|
+
*
|
|
208
|
+
* Returns a result, never a bare list, because "the fetch failed" and "this
|
|
209
|
+
* marketplace has no plugins" used to be the same value (`[]`) and the caller
|
|
210
|
+
* could not tell them apart. That single conflation is what let a rate-limited
|
|
211
|
+
* request render as "you are up to date": the empty list fell through to the
|
|
212
|
+
* on-disk clone, and a stale clone's version compared equal to the installed
|
|
213
|
+
* one. A failure must be a value the caller has to look at.
|
|
214
|
+
*/
|
|
51
215
|
export async function fetchMarketplacePlugins(
|
|
52
216
|
marketplaceName: string,
|
|
53
217
|
repo: string,
|
|
54
|
-
): Promise<
|
|
218
|
+
): Promise<MarketplaceFetchResult> {
|
|
55
219
|
// Check cache first - session-level, no TTL
|
|
56
220
|
const cached = marketplaceCache.get(marketplaceName);
|
|
57
221
|
if (cached) {
|
|
58
|
-
return cached;
|
|
222
|
+
return { plugins: cached };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A fresh failure is reused rather than re-attempted: see `failureCache`.
|
|
226
|
+
const priorFailure = failureCache.get(marketplaceName);
|
|
227
|
+
if (priorFailure && now() - priorFailure.at < NEGATIVE_TTL_MS) {
|
|
228
|
+
return { plugins: [], failure: priorFailure.failure };
|
|
59
229
|
}
|
|
60
230
|
|
|
61
231
|
// Validate repo format to prevent SSRF
|
|
62
232
|
if (!isValidGitHubRepo(repo)) {
|
|
63
|
-
|
|
64
|
-
|
|
233
|
+
return fail(
|
|
234
|
+
marketplaceName,
|
|
235
|
+
"invalid-repo",
|
|
236
|
+
`not a valid GitHub repo: "${repo}"`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const host: GitHubHost = "raw.githubusercontent.com";
|
|
241
|
+
|
|
242
|
+
// Cooldowns recorded by a PREVIOUS launch count too. Without this the gate is
|
|
243
|
+
// per-process, and a TUI's process lives for one screen session.
|
|
244
|
+
await hydrateGitHubBudget();
|
|
245
|
+
|
|
246
|
+
// Already known to be rate-limited: do not spend an attempt to rediscover it.
|
|
247
|
+
// The budget is per-host and shared with every other service, so six
|
|
248
|
+
// marketplaces used to trip the same limit six times over — and on some
|
|
249
|
+
// endpoints a rejected request extends the penalty.
|
|
250
|
+
const cooling = cooldownFor(host);
|
|
251
|
+
if (cooling) {
|
|
252
|
+
return fail(
|
|
253
|
+
marketplaceName,
|
|
254
|
+
"rate-limited",
|
|
255
|
+
describeCooldown(cooling),
|
|
256
|
+
429,
|
|
257
|
+
cooling.until,
|
|
258
|
+
);
|
|
65
259
|
}
|
|
66
260
|
|
|
67
261
|
try {
|
|
68
262
|
// Fetch marketplace.json from GitHub
|
|
69
263
|
const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
|
|
70
264
|
const response = await fetch(url, {
|
|
71
|
-
signal: AbortSignal.timeout(
|
|
265
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
72
266
|
});
|
|
73
267
|
|
|
74
268
|
if (!response.ok) {
|
|
75
|
-
|
|
76
|
-
|
|
269
|
+
// 429/403 are the rate-limit shapes GitHub returns for unauthenticated
|
|
270
|
+
// traffic. Called out separately because the fix is "wait, or
|
|
271
|
+
// authenticate", not "check your network".
|
|
272
|
+
const rateLimited = response.status === 429 || response.status === 403;
|
|
273
|
+
if (rateLimited) {
|
|
274
|
+
// Hand the response headers over: api.github.com states when the window
|
|
275
|
+
// reopens, raw.githubusercontent.com states nothing, and the budget
|
|
276
|
+
// module is what knows the difference.
|
|
277
|
+
const cooldown = recordRateLimit(host, response.headers);
|
|
278
|
+
return fail(
|
|
279
|
+
marketplaceName,
|
|
280
|
+
"rate-limited",
|
|
281
|
+
describeCooldown(cooldown),
|
|
282
|
+
response.status,
|
|
283
|
+
cooldown.until,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return fail(
|
|
287
|
+
marketplaceName,
|
|
288
|
+
"http",
|
|
289
|
+
`HTTP ${response.status} ${response.statusText}`.trim(),
|
|
290
|
+
response.status,
|
|
291
|
+
);
|
|
77
292
|
}
|
|
78
293
|
|
|
79
294
|
// Validate content-type
|
|
@@ -83,55 +298,24 @@ export async function fetchMarketplacePlugins(
|
|
|
83
298
|
!contentType.includes("application/json") &&
|
|
84
299
|
!contentType.includes("text/plain")
|
|
85
300
|
) {
|
|
86
|
-
|
|
87
|
-
|
|
301
|
+
return fail(
|
|
302
|
+
marketplaceName,
|
|
303
|
+
"invalid-content",
|
|
304
|
+
`unexpected content-type "${contentType}"`,
|
|
305
|
+
);
|
|
88
306
|
}
|
|
89
307
|
|
|
90
|
-
|
|
91
|
-
name: string;
|
|
92
|
-
version?: string | null;
|
|
93
|
-
description?: string;
|
|
94
|
-
category?: string;
|
|
95
|
-
author?: { name: string; email?: string };
|
|
96
|
-
homepage?: string;
|
|
97
|
-
tags?: string[];
|
|
98
|
-
releases?: unknown;
|
|
99
|
-
}
|
|
100
|
-
const data = (await response.json()) as {
|
|
101
|
-
plugins?: RawPlugin[];
|
|
102
|
-
metadata?: { version?: string };
|
|
103
|
-
};
|
|
104
|
-
const plugins: MarketplacePlugin[] = [];
|
|
105
|
-
|
|
106
|
-
// Capture the marketplace's declared version (if any) so the TUI can
|
|
107
|
-
// display it. Absent/blank versions are simply not cached.
|
|
108
|
-
const version = data.metadata?.version;
|
|
109
|
-
if (typeof version === "string" && version.length > 0) {
|
|
110
|
-
marketplaceVersionCache.set(marketplaceName, version);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (data.plugins && Array.isArray(data.plugins)) {
|
|
114
|
-
for (const plugin of data.plugins) {
|
|
115
|
-
plugins.push({
|
|
116
|
-
name: plugin.name,
|
|
117
|
-
version: plugin.version || null,
|
|
118
|
-
description: plugin.description || "",
|
|
119
|
-
category: plugin.category,
|
|
120
|
-
author: plugin.author,
|
|
121
|
-
homepage: plugin.homepage,
|
|
122
|
-
tags: plugin.tags,
|
|
123
|
-
releases: normalizeReleases(plugin.releases),
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
}
|
|
308
|
+
const plugins = normalizeCatalogJson(marketplaceName, await response.json());
|
|
127
309
|
|
|
128
310
|
// Cache the result (session-level)
|
|
129
311
|
marketplaceCache.set(marketplaceName, plugins);
|
|
312
|
+
// A success retires any recorded failure, so the banner clears itself.
|
|
313
|
+
failureCache.delete(marketplaceName);
|
|
314
|
+
recordSuccess(host);
|
|
130
315
|
|
|
131
|
-
return plugins;
|
|
316
|
+
return { plugins };
|
|
132
317
|
} catch (error) {
|
|
133
|
-
|
|
134
|
-
return [];
|
|
318
|
+
return classifyThrown(marketplaceName, error);
|
|
135
319
|
}
|
|
136
320
|
}
|
|
137
321
|
|
|
@@ -148,24 +332,63 @@ export async function fetchMarketplacePlugins(
|
|
|
148
332
|
* The two shapes are nearly identical; we drop a couple of LocalMarketplacePlugin
|
|
149
333
|
* fields (lspServers, agents, etc.) that MarketplacePlugin doesn't carry
|
|
150
334
|
* since the caller doesn't read them at this layer.
|
|
335
|
+
*
|
|
336
|
+
* The fallback stays — being offline should not make every plugin read as
|
|
337
|
+
* deprecated — but the return value now says WHICH source answered. The clone is
|
|
338
|
+
* a usable catalog, not an authoritative one: nothing refreshes it when a
|
|
339
|
+
* marketplace carries `autoUpdate: false`, so its versions can be arbitrarily
|
|
340
|
+
* old. A caller that reports "up to date" off a `local-clone` answer is
|
|
341
|
+
* reporting a guess as a fact.
|
|
151
342
|
*/
|
|
152
343
|
export async function resolveMarketplacePlugins(
|
|
153
344
|
mpName: string,
|
|
154
345
|
repo: string,
|
|
155
346
|
localMarketplaces: Map<string, LocalMarketplace>,
|
|
156
|
-
): Promise<
|
|
347
|
+
): Promise<MarketplaceResolution> {
|
|
348
|
+
// Disk cache first, so a relaunch costs no network at all. Measured before this
|
|
349
|
+
// existed: every process spent six requests and ~13s re-fetching catalogs it had
|
|
350
|
+
// already read minutes earlier.
|
|
351
|
+
const cached = await readCachedCatalog(mpName);
|
|
352
|
+
if (cached) {
|
|
353
|
+
return {
|
|
354
|
+
plugins: cached.plugins,
|
|
355
|
+
source: cached.source as CatalogSource,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
157
359
|
const remote = await fetchMarketplacePlugins(mpName, repo);
|
|
158
|
-
if (remote.length > 0)
|
|
360
|
+
if (remote.plugins.length > 0) {
|
|
361
|
+
void writeCachedCatalog(mpName, remote.plugins, "remote");
|
|
362
|
+
return { plugins: remote.plugins, source: "remote" };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// HTTP failed — most often rate-limited. Before falling back to the pinned
|
|
366
|
+
// working tree, ask git for upstream's catalog. `git fetch` moves only remote
|
|
367
|
+
// refs, so this respects `autoUpdate: false` (the install source stays pinned)
|
|
368
|
+
// while still answering "what version exists upstream". It is also on a
|
|
369
|
+
// different transport, so a raw.githubusercontent.com rate limit does not
|
|
370
|
+
// affect it.
|
|
371
|
+
const viaGit = await readCatalogFromRemote(mpName);
|
|
372
|
+
if (viaGit && viaGit.length > 0) {
|
|
373
|
+
void writeCachedCatalog(mpName, viaGit, "remote-git");
|
|
374
|
+
return { plugins: viaGit, source: "remote-git", failure: remote.failure };
|
|
375
|
+
}
|
|
159
376
|
|
|
160
377
|
const local = localMarketplaces.get(mpName);
|
|
161
|
-
if (!local || local.plugins.length === 0)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
378
|
+
if (!local || local.plugins.length === 0) {
|
|
379
|
+
return { plugins: [], source: "none", failure: remote.failure };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
plugins: local.plugins.map((p) => ({
|
|
384
|
+
name: p.name,
|
|
385
|
+
version: p.version,
|
|
386
|
+
description: p.description,
|
|
387
|
+
category: p.category,
|
|
388
|
+
author: p.author,
|
|
389
|
+
releases: p.releases,
|
|
390
|
+
})),
|
|
391
|
+
source: "local-clone",
|
|
392
|
+
failure: remote.failure,
|
|
393
|
+
};
|
|
171
394
|
}
|