Skip to content

Commit 9f2dd7f

Browse files
committed
fix: close remaining plugin workflow review issues
1 parent 2368f71 commit 9f2dd7f

16 files changed

Lines changed: 295 additions & 125 deletions

backend/src/api/routes/plugins.js

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ async function fetchOfficialCatalog(force = false) {
5656
}
5757

5858
catalogCache.pending = (async () => {
59-
const response = await fetch(OFFICIAL_CATALOG_URL);
59+
const response = await fetchWithTimeout(OFFICIAL_CATALOG_URL);
6060

6161
if (!response.ok) {
6262
const errorText = await response.text();
@@ -65,19 +65,19 @@ async function fetchOfficialCatalog(force = false) {
6565
}
6666

6767
const data = await response.json();
68-
catalogCache = {
69-
data,
70-
expiresAt: Date.now() + CATALOG_TTL_MS,
71-
pending: null,
72-
};
68+
catalogCache.data = data;
69+
catalogCache.expiresAt = Date.now() + CATALOG_TTL_MS;
7370
return data;
7471
})();
7572

7673
try {
7774
return await catalogCache.pending;
7875
} catch (error) {
79-
catalogCache.pending = null;
76+
catalogCache.data = null;
77+
catalogCache.expiresAt = 0;
8078
throw error;
79+
} finally {
80+
catalogCache.pending = null;
8181
}
8282
}
8383

@@ -141,21 +141,22 @@ function parseGithubRepoInfo(repoUrl) {
141141
}
142142

143143
async function fetchGithubReadme(owner, repo) {
144-
const readmeUrls = [
145-
`https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`,
146-
`https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`,
147-
`https://raw.githubusercontent.com/${owner}/${repo}/main/readme.md`,
148-
`https://raw.githubusercontent.com/${owner}/${repo}/master/readme.md`,
149-
];
150-
151-
for (const url of readmeUrls) {
152-
const readmeResponse = await fetchWithTimeout(url, { headers: getGithubHeaders() });
153-
if (readmeResponse.ok) {
154-
return readmeResponse.text();
144+
const response = await fetchWithTimeout(
145+
`https://api.github.com/repos/${owner}/${repo}/readme`,
146+
{ headers: getGithubHeaders({ 'Accept': 'application/vnd.github.raw+json' }) }
147+
);
148+
149+
if (!response.ok) {
150+
if (response.status === 403) {
151+
const remaining = response.headers.get('x-ratelimit-remaining');
152+
if (remaining === '0') {
153+
console.warn(`[GitHub README] Rate limit reached while loading README for ${owner}/${repo}.`);
154+
}
155155
}
156+
return null;
156157
}
157158

158-
return null;
159+
return response.text();
159160
}
160161

161162
async function renderGithubMarkdown(markdown, owner, repo) {

backend/src/core/PluginManager.js

Lines changed: 107 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const DATA_DIR = path.join(os.homedir(), '.blockmine');
1313
const PLUGINS_BASE_DIR = path.join(DATA_DIR, 'storage', 'plugins');
1414

1515
const TELEMETRY_ENABLED = true;
16-
const STATS_SERVER_URL = 'http://185.65.200.184:3000';
16+
const STATS_SERVER_URL = process.env.STATS_SERVER_URL || 'http://185.65.200.184:3000';
1717

1818
function normalizeGithubRepoUrl(repoUrl) {
1919
if (!repoUrl || typeof repoUrl !== 'string') return null;
@@ -97,6 +97,83 @@ async function fetchJsonWithTimeout(url, timeoutMs = 7000) {
9797
}
9898
}
9999

100+
async function fetchGithubResponseWithTimeout(url, options = {}, timeoutMs = 10000) {
101+
const controller = new AbortController();
102+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
103+
104+
try {
105+
const response = await fetch(url, {
106+
...options,
107+
headers: getGithubHeaders(options.headers || {}),
108+
signal: controller.signal
109+
});
110+
111+
if (!response.ok) {
112+
const error = new Error(`GitHub request failed with status ${response.status}.`);
113+
error.status = response.status;
114+
throw error;
115+
}
116+
117+
return response;
118+
} catch (error) {
119+
if (error.name === 'AbortError') {
120+
const timeoutError = new Error('GitHub request timed out.');
121+
timeoutError.name = 'AbortError';
122+
throw timeoutError;
123+
}
124+
125+
throw error;
126+
} finally {
127+
clearTimeout(timeoutId);
128+
}
129+
}
130+
131+
async function fetchGithubRepoInfo(repoUrl) {
132+
const ownerRepo = parseGithubOwnerRepo(repoUrl);
133+
if (!ownerRepo) return null;
134+
135+
return fetchJsonWithTimeout(`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}`);
136+
}
137+
138+
async function downloadGithubArchive(repoUrl, ref) {
139+
const ownerRepo = parseGithubOwnerRepo(repoUrl);
140+
if (!ownerRepo) {
141+
throw new Error('Invalid GitHub repository URL.');
142+
}
143+
144+
const response = await fetchGithubResponseWithTimeout(
145+
`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}/zipball/${encodeURIComponent(ref)}`,
146+
{ headers: { Accept: 'application/vnd.github+json' } },
147+
15000
148+
);
149+
150+
return response;
151+
}
152+
153+
async function fetchGithubPackageVersion(repoUrl, ref = null) {
154+
const ownerRepo = parseGithubOwnerRepo(repoUrl);
155+
if (!ownerRepo) return null;
156+
157+
const resolvedRef = ref || (await fetchGithubRepoInfo(repoUrl))?.default_branch;
158+
if (!resolvedRef) return null;
159+
160+
const packageJsonData = await fetchJsonWithTimeout(
161+
`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}/contents/package.json?ref=${encodeURIComponent(resolvedRef)}`
162+
);
163+
164+
if (!packageJsonData?.content) {
165+
return null;
166+
}
167+
168+
try {
169+
const packageJsonRaw = Buffer.from(packageJsonData.content, packageJsonData.encoding || 'base64').toString('utf8');
170+
const packageJson = JSON.parse(packageJsonRaw);
171+
return typeof packageJson?.version === 'string' ? packageJson.version : null;
172+
} catch {
173+
return null;
174+
}
175+
}
176+
100177
async function fetchLatestGithubVersionTag(repoUrl) {
101178
const ownerRepo = parseGithubOwnerRepo(repoUrl);
102179
if (!ownerRepo) return null;
@@ -307,33 +384,30 @@ class PluginManager {
307384
try {
308385
const url = new URL(repoUrl);
309386
const repoPath = url.pathname.replace(/^\/|\.git$/g, '');
387+
const repoInfo = tag ? null : await fetchGithubRepoInfo(repoUrl);
388+
if (repoInfo?.default_branch) {
389+
sourceRef = repoInfo.default_branch;
390+
}
310391

311392
let response;
312393

313-
// Если указан тег - скачиваем конкретный релиз
314394
if (tag) {
315-
const archiveUrlTag = `https://github.com/${repoPath}/archive/refs/tags/${encodeURIComponent(tag)}.zip`;
316395
console.log(`[PluginManager] Скачиваем релиз ${tag} из ${repoUrl}...`);
317396
try {
318-
response = await fetch(archiveUrlTag);
397+
response = await downloadGithubArchive(repoUrl, tag);
319398
} catch (err) {
320399
throw new Error(`Ошибка сети при скачивании релиза ${tag}: ${err.message || err}`);
321400
}
322-
if (!response.ok) {
323-
throw new Error(`Не удалось скачать релиз ${tag}. Статус: ${response.status}. Возможно, тег не существует.`);
324-
}
325401
} else {
326-
// Если тег не указан - скачиваем последний коммит из main/master (старое поведение)
327-
const archiveUrlMain = `https://github.com/${repoPath}/archive/refs/heads/main.zip`;
328-
const archiveUrlMaster = `https://github.com/${repoPath}/archive/refs/heads/master.zip`;
329-
330-
response = await fetch(archiveUrlMain);
331-
if (!response.ok) {
332-
console.log(`[PluginManager] Ветка 'main' не найдена для ${repoUrl}, пробую 'master'...`);
333-
sourceRef = 'master';
334-
response = await fetch(archiveUrlMaster);
335-
if (!response.ok) {
336-
throw new Error(`Не удалось скачать архив плагина. Статус: ${response.status}`);
402+
try {
403+
response = await downloadGithubArchive(repoUrl, sourceRef);
404+
} catch (err) {
405+
if (!repoInfo && sourceRef !== 'master') {
406+
console.log(`[PluginManager] Ветка '${sourceRef}' не найдена для ${repoUrl}, пробую 'master'...`);
407+
sourceRef = 'master';
408+
response = await downloadGithubArchive(repoUrl, sourceRef);
409+
} else {
410+
throw new Error(`Не удалось скачать архив плагина: ${err.message || err}`);
337411
}
338412
}
339413
}
@@ -540,30 +614,37 @@ class PluginManager {
540614
catalogMapByName.get(String(plugin.name).toLowerCase()) ||
541615
(repoName ? catalogMapByRepoName.get(repoName) : null);
542616

617+
const targetRepoUrl = catalogInfo?.repoUrl || normalizedSourceUri || normalizeGithubRepoUrl(plugin.sourceUri) || plugin.sourceUri;
543618
let latestTagRaw =
544619
catalogInfo?.latestTag ||
545620
catalogInfo?.recommendedVersion ||
546621
catalogInfo?.version ||
547622
catalogInfo?.latestVersion ||
548623
catalogInfo?.tag;
549624

550-
if (!catalogInfo) continue;
551-
552625
if (!latestTagRaw) {
553-
const cacheKey = normalizedSourceUri || plugin.sourceUri || plugin.name;
626+
const cacheKey = targetRepoUrl || plugin.name;
554627
if (latestTagCache.has(cacheKey)) {
555628
latestTagRaw = latestTagCache.get(cacheKey);
556629
} else {
557-
const fetchedTag = catalogInfo.repoUrl ? await fetchLatestGithubVersionTag(catalogInfo.repoUrl) : null;
630+
const fetchedTag = targetRepoUrl ? await fetchLatestGithubVersionTag(targetRepoUrl) : null;
558631
latestTagCache.set(cacheKey, fetchedTag);
559632
latestTagRaw = fetchedTag;
560633
}
561634
}
562635

563-
if (!latestTagRaw) continue;
636+
let latestVersionRaw = latestTagRaw;
637+
if (!latestVersionRaw && targetRepoUrl) {
638+
latestVersionRaw = await fetchGithubPackageVersion(
639+
targetRepoUrl,
640+
plugin.sourceRefType === 'branch' ? plugin.sourceRef : null
641+
);
642+
}
643+
644+
if (!latestVersionRaw) continue;
564645

565646
const localSemver = semver.coerce(plugin.version);
566-
const remoteSemver = semver.coerce(latestTagRaw);
647+
const remoteSemver = semver.coerce(latestVersionRaw);
567648
if (!localSemver || !remoteSemver) continue;
568649

569650
if (semver.gt(remoteSemver.version, localSemver.version)) {
@@ -573,8 +654,8 @@ class PluginManager {
573654
sourceUri: plugin.sourceUri,
574655
currentVersion: localSemver.version,
575656
recommendedVersion: remoteSemver.version,
576-
latestTag: catalogInfo?.latestTag || latestTagRaw, // 🏷️ Добавляем тег для использования при обновлении
577-
targetRepoUrl: catalogInfo?.repoUrl || plugin.sourceUri,
657+
latestTag: catalogInfo?.latestTag || latestTagRaw || null,
658+
targetRepoUrl,
578659
});
579660
}
580661
} catch (error) {

frontend/src/components/InstalledPluginsView.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ function InstalledPluginCard({
304304
<div className="mb-1 flex items-center gap-2">
305305
<Tooltip>
306306
<TooltipTrigger asChild>
307-
<Link to={`/bots/${botId}/plugins/view/${plugin.name}`} className="cursor-pointer truncate text-lg font-semibold transition-colors hover:text-primary">
307+
<Link to={`/bots/${botId}/plugins/view/${encodeURIComponent(plugin.name)}`} className="cursor-pointer truncate text-lg font-semibold transition-colors hover:text-primary">
308308
{plugin.displayName || plugin.name}
309309
</Link>
310310
</TooltipTrigger>
@@ -441,7 +441,7 @@ function InstalledPluginCard({
441441
<div className="min-w-0 flex-1">
442442
<Tooltip>
443443
<TooltipTrigger asChild>
444-
<Link to={`/bots/${botId}/plugins/view/${plugin.name}`} className="block">
444+
<Link to={`/bots/${botId}/plugins/view/${encodeURIComponent(plugin.name)}`} className="block">
445445
<CardTitle className="line-clamp-2 cursor-pointer text-lg leading-tight transition-colors hover:text-primary">
446446
{plugin.displayName || plugin.name}
447447
</CardTitle>

frontend/src/components/PluginBrowserView.jsx

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import PluginListItem from '@/components/PluginListItem';
1313
import * as Icons from 'lucide-react';
1414
import { FixedSizeGrid, FixedSizeList } from 'react-window';
1515
import { useMediaQuery } from '@/hooks/useMediaQuery';
16+
import { toast } from '@/hooks/use-toast';
1617
import {
1718
PLUGIN_BROWSER_CATEGORIES,
1819
pluginMatchesCategory,
@@ -91,6 +92,9 @@ export default function PluginBrowserView({ botId, isActive, installedPlugins, o
9192
try {
9293
await installPluginFromRepo(botId, pluginToInstall.repoUrl, pluginToInstall.name);
9394
onInstallSuccess();
95+
return true;
96+
} catch {
97+
return false;
9498
} finally {
9599
setInstallingPlugins((previous) => {
96100
const next = new Set(previous);
@@ -101,34 +105,63 @@ export default function PluginBrowserView({ botId, isActive, installedPlugins, o
101105
};
102106

103107
const handleInstall = async (pluginToInstall) => {
104-
const requiredDependencyNames = pluginToInstall.dependencies || [];
105-
if (requiredDependencyNames.length === 0) {
106-
await installSinglePlugin(pluginToInstall);
107-
return;
108-
}
108+
try {
109+
const requiredDependencyNames = pluginToInstall.dependencies || [];
110+
if (requiredDependencyNames.length === 0) {
111+
await installSinglePlugin(pluginToInstall);
112+
return;
113+
}
109114

110-
const installedPluginNames = new Set(installedPlugins.map((plugin) => plugin.name));
111-
const catalogMapByName = new Map(catalog.map((plugin) => [plugin.name, plugin]));
115+
const installedPluginNames = new Set(installedPlugins.map((plugin) => plugin.name));
116+
const catalogMapByName = new Map(catalog.map((plugin) => [plugin.name, plugin]));
112117

113-
const allDependenciesWithStatus = requiredDependencyNames
114-
.map((dependencyName) => {
118+
const unresolvedDependencies = [];
119+
const allDependenciesWithStatus = requiredDependencyNames.map((dependencyName) => {
115120
const pluginInfo = catalogMapByName.get(dependencyName);
116-
return pluginInfo ? { ...pluginInfo, isInstalled: installedPluginNames.has(dependencyName) } : null;
117-
})
118-
.filter(Boolean);
119-
120-
const missingDependencies = allDependenciesWithStatus.filter((dependency) => !dependency.isInstalled);
121+
if (!pluginInfo) {
122+
unresolvedDependencies.push(dependencyName);
123+
return {
124+
id: `missing:${dependencyName}`,
125+
name: dependencyName,
126+
description: t('dependencyDialog.unresolvedDependency', {
127+
name: dependencyName,
128+
defaultValue: 'Dependency "{{name}}" was not found in the official catalog.',
129+
}),
130+
isInstalled: false,
131+
isMissingFromCatalog: true,
132+
};
133+
}
121134

122-
if (missingDependencies.length > 0) {
123-
setDependencyDialogState({
124-
isOpen: true,
125-
mainPlugin: pluginToInstall,
126-
dependencies: allDependenciesWithStatus,
135+
return { ...pluginInfo, isInstalled: installedPluginNames.has(dependencyName), isMissingFromCatalog: false };
127136
});
128-
return;
129-
}
130137

131-
await installSinglePlugin(pluginToInstall);
138+
if (unresolvedDependencies.length > 0) {
139+
toast({
140+
variant: 'destructive',
141+
title: t('dependencyDialog.unresolvedTitle', { defaultValue: 'Missing dependencies' }),
142+
description: t('dependencyDialog.unresolvedDescription', {
143+
dependencies: unresolvedDependencies.join(', '),
144+
defaultValue: 'These dependencies are not in the official catalog: {{dependencies}}',
145+
}),
146+
});
147+
return;
148+
}
149+
150+
const missingDependencies = allDependenciesWithStatus.filter((dependency) => !dependency.isInstalled);
151+
152+
if (missingDependencies.length > 0) {
153+
setDependencyDialogState({
154+
isOpen: true,
155+
mainPlugin: pluginToInstall,
156+
dependencies: allDependenciesWithStatus,
157+
});
158+
return;
159+
}
160+
161+
await installSinglePlugin(pluginToInstall);
162+
} catch {
163+
// apiHelper already reports user-facing errors; keep the handler from bubbling an unhandled promise
164+
}
132165
};
133166

134167
const confirmAndInstallAll = async () => {

0 commit comments

Comments
 (0)