@shnitzel/plugscout 0.3.24 → 0.3.26
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.
|
@@ -19,6 +19,7 @@ import { detectProjectSignals } from '../../recommendation/project-analysis.js';
|
|
|
19
19
|
import { recommend } from '../../recommendation/engine.js';
|
|
20
20
|
import { loadRequirementsProfile } from '../../recommendation/requirements.js';
|
|
21
21
|
import { assessRisk, buildAssessment } from '../../security/assessment.js';
|
|
22
|
+
import { runLiveChecks, formatLiveChecks } from '../../security/live-check.js';
|
|
22
23
|
import { applyQuarantineFromReport, verifyWhitelist } from '../../security/whitelist.js';
|
|
23
24
|
import { runDoctorChecks } from './doctor.js';
|
|
24
25
|
import { renderCsv } from './formatters/csv.js';
|
|
@@ -486,6 +487,7 @@ async function handleShow(args) {
|
|
|
486
487
|
if (!id) {
|
|
487
488
|
throw new Error('Usage: show --id <catalog-id>');
|
|
488
489
|
}
|
|
490
|
+
const noLive = hasFlag(args, '--no-live');
|
|
489
491
|
const [item, whitelist, quarantine, insights] = await Promise.all([
|
|
490
492
|
loadCatalogItemById(id),
|
|
491
493
|
loadWhitelist(),
|
|
@@ -515,14 +517,21 @@ async function handleShow(args) {
|
|
|
515
517
|
printHint(`Install with: plugscout install --id ${item.id} --yes`);
|
|
516
518
|
printHint('Review provenance, risk, and capabilities first. Do not install blindly from a suggestion or score.');
|
|
517
519
|
console.log(`Provenance: source=${item.source} catalogType=${getCatalogType(item)} confidence=${getSourceConfidence(item)}`);
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
520
|
+
const links = buildItemLinks(item);
|
|
521
|
+
if (links.length > 0) {
|
|
522
|
+
console.log('\nLinks:');
|
|
523
|
+
for (const link of links) {
|
|
524
|
+
console.log(` ${link.label}: ${link.url}`);
|
|
525
|
+
}
|
|
523
526
|
}
|
|
524
|
-
if (
|
|
525
|
-
|
|
527
|
+
if (!noLive) {
|
|
528
|
+
process.stdout.write('\x1b[90mRunning live checks…\x1b[0m\r');
|
|
529
|
+
const liveResults = await runLiveChecks(item);
|
|
530
|
+
process.stdout.write(' \r');
|
|
531
|
+
const liveOutput = formatLiveChecks(liveResults);
|
|
532
|
+
if (liveOutput) {
|
|
533
|
+
console.log(liveOutput);
|
|
534
|
+
}
|
|
526
535
|
}
|
|
527
536
|
}
|
|
528
537
|
async function handleSearch(args) {
|
|
@@ -808,6 +817,7 @@ async function handleAssess(args) {
|
|
|
808
817
|
if (!id) {
|
|
809
818
|
throw new Error('Missing --id for assess');
|
|
810
819
|
}
|
|
820
|
+
const noLive = hasFlag(args, '--no-live');
|
|
811
821
|
const found = await loadCatalogItemById(id);
|
|
812
822
|
if (!found) {
|
|
813
823
|
throw new Error(await buildCatalogItemNotFoundMessage(id));
|
|
@@ -816,6 +826,22 @@ async function handleAssess(args) {
|
|
|
816
826
|
console.log(renderJson(assessment));
|
|
817
827
|
await recordItemReview(found.id, 'assess');
|
|
818
828
|
printHint(`Risk scale (lower is safer): ${formatRiskScale(policy)}`);
|
|
829
|
+
const links = buildItemLinks(found);
|
|
830
|
+
if (links.length > 0) {
|
|
831
|
+
console.log('\nLinks:');
|
|
832
|
+
for (const link of links) {
|
|
833
|
+
console.log(` ${link.label}: ${link.url}`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (!noLive) {
|
|
837
|
+
process.stdout.write('\x1b[90mRunning live checks…\x1b[0m\r');
|
|
838
|
+
const liveResults = await runLiveChecks(found);
|
|
839
|
+
process.stdout.write(' \r');
|
|
840
|
+
const liveOutput = formatLiveChecks(liveResults);
|
|
841
|
+
if (liveOutput) {
|
|
842
|
+
console.log(liveOutput);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
819
845
|
}
|
|
820
846
|
async function handleInstall(args) {
|
|
821
847
|
const id = readFlag(args, '--id');
|
|
@@ -1460,3 +1486,43 @@ function getSourceConfidence(item) {
|
|
|
1460
1486
|
}
|
|
1461
1487
|
return 'official';
|
|
1462
1488
|
}
|
|
1489
|
+
function buildItemLinks(item) {
|
|
1490
|
+
const meta = getItemMetadata(item);
|
|
1491
|
+
const links = [];
|
|
1492
|
+
const seen = new Set();
|
|
1493
|
+
function add(label, url) {
|
|
1494
|
+
if (typeof url !== 'string' || !url.startsWith('http'))
|
|
1495
|
+
return;
|
|
1496
|
+
if (seen.has(url))
|
|
1497
|
+
return;
|
|
1498
|
+
seen.add(url);
|
|
1499
|
+
links.push({ label, url });
|
|
1500
|
+
}
|
|
1501
|
+
// Kind-specific derived URLs first (most useful)
|
|
1502
|
+
if (item.kind === 'cursor-extension') {
|
|
1503
|
+
const vsixId = meta.vsixId;
|
|
1504
|
+
if (typeof vsixId === 'string') {
|
|
1505
|
+
add('Marketplace', `https://marketplace.visualstudio.com/items?itemName=${vsixId}`);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (item.kind === 'gemini-extension') {
|
|
1509
|
+
const pkg = meta.npmPackage;
|
|
1510
|
+
if (typeof pkg === 'string') {
|
|
1511
|
+
add('npm', `https://www.npmjs.com/package/${encodeURIComponent(pkg)}`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
// install.url is the most direct link for the user
|
|
1515
|
+
add('Install page', item.install.url);
|
|
1516
|
+
// Metadata links — priority order
|
|
1517
|
+
add('Repository', meta.repositoryUrl);
|
|
1518
|
+
add('Repository', meta.githubUrl);
|
|
1519
|
+
add('Repository', meta.sourceRepo?.toString().startsWith('http') ? meta.sourceRepo : undefined);
|
|
1520
|
+
add('Website', meta.websiteUrl);
|
|
1521
|
+
add('Source page', meta.sourcePage);
|
|
1522
|
+
// GitHub shorthand (e.g. "github/awesome-copilot") → full URL
|
|
1523
|
+
if (typeof meta.sourceRepo === 'string' && !meta.sourceRepo.startsWith('http')) {
|
|
1524
|
+
const ghUrl = `https://github.com/${meta.sourceRepo}`;
|
|
1525
|
+
add('Repository', ghUrl);
|
|
1526
|
+
}
|
|
1527
|
+
return links;
|
|
1528
|
+
}
|
|
@@ -454,10 +454,7 @@ function renderHtml(rows, allItems, stats, policy) {
|
|
|
454
454
|
</html>`;
|
|
455
455
|
}
|
|
456
456
|
function renderDetailCard(entry) {
|
|
457
|
-
const metadata = asMetadata(entry.item.metadata);
|
|
458
457
|
const trustScore = computeTrustScore(entry.item);
|
|
459
|
-
const sourceRepo = typeof metadata.sourceRepo === 'string' ? metadata.sourceRepo : '';
|
|
460
|
-
const sourcePage = typeof metadata.sourcePage === 'string' ? metadata.sourcePage : '';
|
|
461
458
|
const status = entry.blocked ? 'blocked' : entry.approved ? 'approved' : 'allowed';
|
|
462
459
|
const statusClass = entry.blocked ? 'bad' : 'ok';
|
|
463
460
|
const installHint = buildInstallHint(entry.item);
|
|
@@ -473,8 +470,6 @@ function renderDetailCard(entry) {
|
|
|
473
470
|
const bodyId = `body-${safeId}`;
|
|
474
471
|
const searchKey = escapeHtml(`${entry.item.id} ${entry.item.name} ${entry.item.capabilities.join(' ')}`.toLowerCase());
|
|
475
472
|
const plugscoutCmd = `plugscout install --id ${entry.item.id} --yes`;
|
|
476
|
-
const isManualUrl = entry.item.install.kind === 'manual' && typeof entry.item.install.url === 'string' && String(entry.item.install.url).startsWith('http');
|
|
477
|
-
const manualUrl = isManualUrl ? String(entry.item.install.url) : '';
|
|
478
473
|
const previewChips = entry.item.capabilities
|
|
479
474
|
.slice(0, 3)
|
|
480
475
|
.map((cap) => `<span class="chip">${escapeHtml(cap)}</span>`)
|
|
@@ -517,9 +512,7 @@ function renderDetailCard(entry) {
|
|
|
517
512
|
</div>
|
|
518
513
|
${entry.assessment.reasons.length > 0 ? `<div class="line"><span class="label">Risk signals: </span>${escapeHtml(entry.assessment.reasons.join(' · '))}</div>` : ''}
|
|
519
514
|
<div class="line"><span class="label">Provider:</span> ${escapeHtml(entry.item.provider)} · <span class="label">Source:</span> ${escapeHtml(entry.item.source)}</div>
|
|
520
|
-
${
|
|
521
|
-
${sourcePage ? `<div class="line"><span class="label">Page: </span><a class="link" href="${escapeHtml(sourcePage)}" target="_blank" rel="noopener noreferrer">${escapeHtml(sourcePage)}</a></div>` : ''}
|
|
522
|
-
${isManualUrl ? `<div class="line"><span class="label">Install page: </span><a class="link" href="${escapeHtml(manualUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(manualUrl)}</a></div>` : ''}
|
|
515
|
+
${buildItemLinks(entry.item).map(l => `<div class="line"><span class="label">${escapeHtml(l.label)}: </span><a class="link" href="${escapeHtml(l.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(l.url)}</a></div>`).join('')}
|
|
523
516
|
<div class="install-block">
|
|
524
517
|
<div class="install-header">
|
|
525
518
|
<span class="install-label">Install command</span>
|
|
@@ -568,6 +561,43 @@ function asMetadata(value) {
|
|
|
568
561
|
}
|
|
569
562
|
return value;
|
|
570
563
|
}
|
|
564
|
+
function buildItemLinks(item) {
|
|
565
|
+
const meta = asMetadata(item.metadata);
|
|
566
|
+
const links = [];
|
|
567
|
+
const seen = new Set();
|
|
568
|
+
function add(label, url) {
|
|
569
|
+
if (typeof url !== 'string' || !url.startsWith('http'))
|
|
570
|
+
return;
|
|
571
|
+
if (seen.has(url))
|
|
572
|
+
return;
|
|
573
|
+
seen.add(url);
|
|
574
|
+
links.push({ label, url });
|
|
575
|
+
}
|
|
576
|
+
if (item.kind === 'cursor-extension') {
|
|
577
|
+
const vsixId = meta.vsixId;
|
|
578
|
+
if (typeof vsixId === 'string') {
|
|
579
|
+
add('Marketplace', `https://marketplace.visualstudio.com/items?itemName=${vsixId}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (item.kind === 'gemini-extension') {
|
|
583
|
+
const pkg = meta.npmPackage;
|
|
584
|
+
if (typeof pkg === 'string') {
|
|
585
|
+
add('npm', `https://www.npmjs.com/package/${encodeURIComponent(pkg)}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
add('Install page', item.install.url);
|
|
589
|
+
add('Repository', meta.repositoryUrl);
|
|
590
|
+
add('Repository', meta.githubUrl);
|
|
591
|
+
if (typeof meta.sourceRepo === 'string' && meta.sourceRepo.startsWith('http')) {
|
|
592
|
+
add('Repository', meta.sourceRepo);
|
|
593
|
+
}
|
|
594
|
+
else if (typeof meta.sourceRepo === 'string' && meta.sourceRepo.includes('/')) {
|
|
595
|
+
add('Repository', `https://github.com/${meta.sourceRepo}`);
|
|
596
|
+
}
|
|
597
|
+
add('Website', meta.websiteUrl);
|
|
598
|
+
add('Source page', meta.sourcePage);
|
|
599
|
+
return links;
|
|
600
|
+
}
|
|
571
601
|
function escapeHtml(value) {
|
|
572
602
|
return value
|
|
573
603
|
.replaceAll('&', '&')
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { getStatePath } from '../lib/paths.js';
|
|
4
|
+
const TTL_MS = {
|
|
5
|
+
'osv': 6 * 60 * 60 * 1000,
|
|
6
|
+
'npm-registry': 6 * 60 * 60 * 1000,
|
|
7
|
+
'vscode-marketplace': 6 * 60 * 60 * 1000,
|
|
8
|
+
'github': 60 * 60 * 1000,
|
|
9
|
+
'url-health': 60 * 60 * 1000,
|
|
10
|
+
};
|
|
11
|
+
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
|
|
12
|
+
async function readCache(itemId) {
|
|
13
|
+
const p = getStatePath('data', 'live-checks', `${itemId.replace(/[^a-zA-Z0-9-_.]/g, '_')}.json`);
|
|
14
|
+
try {
|
|
15
|
+
const entry = await fs.readJson(p);
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
// Each result may have a different TTL — use the shortest applicable
|
|
18
|
+
const allFresh = entry.results.every(r => {
|
|
19
|
+
const ttl = TTL_MS[r.source] ?? DEFAULT_TTL_MS;
|
|
20
|
+
return now - new Date(r.checkedAt).getTime() < ttl;
|
|
21
|
+
});
|
|
22
|
+
return allFresh ? entry.results : null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function writeCache(itemId, results) {
|
|
29
|
+
const p = getStatePath('data', 'live-checks', `${itemId.replace(/[^a-zA-Z0-9-_.]/g, '_')}.json`);
|
|
30
|
+
const entry = { results, cachedAt: new Date().toISOString() };
|
|
31
|
+
try {
|
|
32
|
+
await fs.ensureFile(p);
|
|
33
|
+
await fs.writeJson(p, entry, { spaces: 2 });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// cache write failure is non-fatal
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function fetchWithTimeout(url, options, timeoutMs) {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
42
|
+
try {
|
|
43
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function checkOsv(pkg) {
|
|
50
|
+
const checkedAt = new Date().toISOString();
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetchWithTimeout('https://api.osv.dev/v1/query', {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ package: { name: pkg, ecosystem: 'npm' } }),
|
|
56
|
+
}, 6000);
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
return { source: 'osv', label: 'OSV vulnerability database', status: 'error', findings: [], checkedAt };
|
|
59
|
+
}
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
const vulns = data.vulns ?? [];
|
|
62
|
+
if (vulns.length === 0) {
|
|
63
|
+
return {
|
|
64
|
+
source: 'osv', label: 'OSV vulnerability database', status: 'clean',
|
|
65
|
+
findings: [{ severity: 'info', text: 'No known vulnerabilities' }],
|
|
66
|
+
checkedAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
source: 'osv', label: 'OSV vulnerability database', status: 'flagged',
|
|
71
|
+
findings: vulns.slice(0, 5).map(v => ({
|
|
72
|
+
severity: 'critical',
|
|
73
|
+
text: `${v.id}${v.summary ? ': ' + v.summary.slice(0, 100) : ''}`,
|
|
74
|
+
})),
|
|
75
|
+
checkedAt,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { source: 'osv', label: 'OSV vulnerability database', status: 'error', findings: [{ severity: 'info', text: 'Check timed out' }], checkedAt };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function checkNpmRegistry(pkg) {
|
|
83
|
+
const checkedAt = new Date().toISOString();
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetchWithTimeout(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`, { headers: { 'User-Agent': 'plugscout/1.0' } }, 6000);
|
|
86
|
+
if (!res.ok) {
|
|
87
|
+
return { source: 'npm-registry', label: 'npm registry', status: 'unavailable', findings: [{ severity: 'warn', text: `Package not found (HTTP ${res.status})` }], checkedAt };
|
|
88
|
+
}
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
if (data.deprecated) {
|
|
91
|
+
const msg = typeof data.deprecated === 'string' ? data.deprecated : 'package is deprecated';
|
|
92
|
+
return {
|
|
93
|
+
source: 'npm-registry', label: 'npm registry', status: 'flagged',
|
|
94
|
+
findings: [{ severity: 'warn', text: `Deprecated: ${msg}` }],
|
|
95
|
+
checkedAt,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
source: 'npm-registry', label: 'npm registry', status: 'clean',
|
|
100
|
+
findings: [{ severity: 'info', text: `Latest: v${data.version ?? 'unknown'} — not deprecated` }],
|
|
101
|
+
checkedAt,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return { source: 'npm-registry', label: 'npm registry', status: 'error', findings: [{ severity: 'info', text: 'Check timed out' }], checkedAt };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function checkVscodeMarketplace(vsixId) {
|
|
109
|
+
const checkedAt = new Date().toISOString();
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetchWithTimeout('https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
'Accept': 'application/json;api-version=7.2-preview.1',
|
|
116
|
+
'User-Agent': 'plugscout/1.0',
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
filters: [{ criteria: [{ filterType: 7, value: vsixId }] }],
|
|
120
|
+
flags: 0x200 | 0x100 | 0x20, // statistics + assetUri + excludeNonValidated
|
|
121
|
+
}),
|
|
122
|
+
}, 8000);
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
return { source: 'vscode-marketplace', label: 'VS Code Marketplace', status: 'error', findings: [], checkedAt };
|
|
125
|
+
}
|
|
126
|
+
const data = await res.json();
|
|
127
|
+
const ext = data.results?.[0]?.extensions?.[0];
|
|
128
|
+
if (!ext) {
|
|
129
|
+
return {
|
|
130
|
+
source: 'vscode-marketplace', label: 'VS Code Marketplace', status: 'unavailable',
|
|
131
|
+
findings: [{ severity: 'warn', text: 'Extension not found in marketplace — may have been removed' }],
|
|
132
|
+
checkedAt,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const findings = [];
|
|
136
|
+
const verified = ext.publisher.isDomainVerified ?? false;
|
|
137
|
+
const latest = ext.versions?.[0];
|
|
138
|
+
const installs = ext.statistics?.find(s => s.statisticName === 'install')?.value ?? 0;
|
|
139
|
+
findings.push({
|
|
140
|
+
severity: verified ? 'info' : 'warn',
|
|
141
|
+
text: verified ? 'Publisher domain verified' : 'Publisher not domain-verified',
|
|
142
|
+
});
|
|
143
|
+
if (latest) {
|
|
144
|
+
findings.push({ severity: 'info', text: `v${latest.version} — updated ${latest.lastUpdated.slice(0, 10)}` });
|
|
145
|
+
}
|
|
146
|
+
if (installs > 0) {
|
|
147
|
+
findings.push({ severity: 'info', text: `${installs.toLocaleString()} installs` });
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
source: 'vscode-marketplace', label: 'VS Code Marketplace',
|
|
151
|
+
status: verified ? 'clean' : 'flagged',
|
|
152
|
+
findings, checkedAt,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return { source: 'vscode-marketplace', label: 'VS Code Marketplace', status: 'error', findings: [{ severity: 'info', text: 'Check timed out' }], checkedAt };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function extractGithubOwnerRepo(url) {
|
|
160
|
+
const m = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\/|$|#|\?|\.git)/);
|
|
161
|
+
return m ? m[1] : null;
|
|
162
|
+
}
|
|
163
|
+
async function checkGithubRepo(ownerRepo) {
|
|
164
|
+
const checkedAt = new Date().toISOString();
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetchWithTimeout(`https://api.github.com/repos/${ownerRepo}`, { headers: { 'User-Agent': 'plugscout/1.0', 'Accept': 'application/vnd.github.v3+json' } }, 6000);
|
|
167
|
+
if (res.status === 404) {
|
|
168
|
+
return {
|
|
169
|
+
source: 'github', label: 'GitHub repository', status: 'unavailable',
|
|
170
|
+
findings: [{ severity: 'warn', text: 'Repository not found or private' }],
|
|
171
|
+
checkedAt,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (res.status === 403) {
|
|
175
|
+
return { source: 'github', label: 'GitHub repository', status: 'error', findings: [{ severity: 'info', text: 'Rate limited — try again later' }], checkedAt };
|
|
176
|
+
}
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
return { source: 'github', label: 'GitHub repository', status: 'error', findings: [], checkedAt };
|
|
179
|
+
}
|
|
180
|
+
const repo = await res.json();
|
|
181
|
+
const findings = [];
|
|
182
|
+
if (repo.disabled) {
|
|
183
|
+
findings.push({ severity: 'critical', text: 'Repository is disabled' });
|
|
184
|
+
}
|
|
185
|
+
else if (repo.archived) {
|
|
186
|
+
findings.push({ severity: 'warn', text: 'Repository is archived — no longer maintained' });
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
const lastPush = repo.pushed_at ? repo.pushed_at.slice(0, 10) : 'unknown';
|
|
190
|
+
findings.push({ severity: 'info', text: `Active — last push: ${lastPush}` });
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
source: 'github', label: 'GitHub repository',
|
|
194
|
+
status: (repo.archived || repo.disabled) ? 'flagged' : 'clean',
|
|
195
|
+
findings, checkedAt,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return { source: 'github', label: 'GitHub repository', status: 'error', findings: [{ severity: 'info', text: 'Check timed out' }], checkedAt };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function checkUrlHealth(url) {
|
|
203
|
+
const checkedAt = new Date().toISOString();
|
|
204
|
+
try {
|
|
205
|
+
const res = await fetchWithTimeout(url, { method: 'HEAD', headers: { 'User-Agent': 'plugscout/1.0' } }, 6000);
|
|
206
|
+
// 405 = HEAD not allowed but server is live
|
|
207
|
+
const reachable = res.status < 400 || res.status === 405;
|
|
208
|
+
return {
|
|
209
|
+
source: 'url-health', label: 'Install URL',
|
|
210
|
+
status: reachable ? 'clean' : 'flagged',
|
|
211
|
+
findings: [{ severity: reachable ? 'info' : 'warn', text: `HTTP ${res.status} — ${reachable ? 'reachable' : 'not reachable'}` }],
|
|
212
|
+
checkedAt,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return {
|
|
217
|
+
source: 'url-health', label: 'Install URL', status: 'flagged',
|
|
218
|
+
findings: [{ severity: 'warn', text: 'URL unreachable' }],
|
|
219
|
+
checkedAt,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function asMetadata(value) {
|
|
224
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
225
|
+
return {};
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
function findGithubOwnerRepo(item, meta) {
|
|
229
|
+
const candidates = [meta.repositoryUrl, meta.githubUrl, meta.sourceRepo];
|
|
230
|
+
for (const c of candidates) {
|
|
231
|
+
if (typeof c === 'string') {
|
|
232
|
+
if (c.includes('github.com')) {
|
|
233
|
+
const r = extractGithubOwnerRepo(c);
|
|
234
|
+
if (r)
|
|
235
|
+
return r;
|
|
236
|
+
}
|
|
237
|
+
else if (!c.startsWith('http') && c.includes('/')) {
|
|
238
|
+
return c;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const installUrl = item.install.url;
|
|
243
|
+
if (typeof installUrl === 'string' && installUrl.includes('github.com')) {
|
|
244
|
+
return extractGithubOwnerRepo(installUrl);
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
export async function runLiveChecks(item, opts = {}) {
|
|
249
|
+
if (!opts.noCache) {
|
|
250
|
+
const cached = await readCache(item.id);
|
|
251
|
+
if (cached)
|
|
252
|
+
return cached;
|
|
253
|
+
}
|
|
254
|
+
const meta = asMetadata(item.metadata);
|
|
255
|
+
const checks = [];
|
|
256
|
+
const npmPkg = typeof meta.npmPackage === 'string' ? meta.npmPackage : null;
|
|
257
|
+
if (npmPkg) {
|
|
258
|
+
checks.push(checkOsv(npmPkg));
|
|
259
|
+
checks.push(checkNpmRegistry(npmPkg));
|
|
260
|
+
}
|
|
261
|
+
if (item.kind === 'cursor-extension') {
|
|
262
|
+
const vsixId = typeof meta.vsixId === 'string' ? meta.vsixId : null;
|
|
263
|
+
if (vsixId) {
|
|
264
|
+
checks.push(checkVscodeMarketplace(vsixId));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const ghOwnerRepo = findGithubOwnerRepo(item, meta);
|
|
268
|
+
if (ghOwnerRepo) {
|
|
269
|
+
checks.push(checkGithubRepo(ghOwnerRepo));
|
|
270
|
+
}
|
|
271
|
+
if (item.kind === 'claude-plugin' || item.kind === 'claude-connector') {
|
|
272
|
+
const installUrl = item.install.url;
|
|
273
|
+
if (typeof installUrl === 'string' && installUrl.startsWith('https://')) {
|
|
274
|
+
checks.push(checkUrlHealth(installUrl));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (checks.length === 0)
|
|
278
|
+
return [];
|
|
279
|
+
const results = await Promise.all(checks);
|
|
280
|
+
await writeCache(item.id, results);
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
export function formatLiveChecks(results) {
|
|
284
|
+
if (results.length === 0)
|
|
285
|
+
return '';
|
|
286
|
+
const lines = ['\nLive checks:'];
|
|
287
|
+
for (const r of results) {
|
|
288
|
+
const icon = r.status === 'clean' ? '\x1b[32m✓\x1b[0m'
|
|
289
|
+
: r.status === 'flagged' ? '\x1b[31m✗\x1b[0m'
|
|
290
|
+
: r.status === 'unavailable' ? '\x1b[33m?\x1b[0m'
|
|
291
|
+
: '\x1b[90m~\x1b[0m';
|
|
292
|
+
const cached = new Date(r.checkedAt).getTime() < Date.now() - 10000 ? ' \x1b[90m(cached)\x1b[0m' : '';
|
|
293
|
+
lines.push(` ${icon} ${r.label}${cached}`);
|
|
294
|
+
for (const f of r.findings) {
|
|
295
|
+
const fc = f.severity === 'critical' ? '\x1b[31m' : f.severity === 'warn' ? '\x1b[33m' : '\x1b[90m';
|
|
296
|
+
lines.push(` ${fc}${f.text}\x1b[0m`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return lines.join('\n');
|
|
300
|
+
}
|
|
301
|
+
export async function clearLiveCheckCache(itemId) {
|
|
302
|
+
if (itemId) {
|
|
303
|
+
const p = getStatePath('data', 'live-checks', `${itemId.replace(/[^a-zA-Z0-9-_.]/g, '_')}.json`);
|
|
304
|
+
await fs.remove(p);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
await fs.emptyDir(getStatePath('data', 'live-checks'));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Suppress unused import warning — path is used implicitly via getStatePath internals
|
|
311
|
+
void path.resolve;
|
package/package.json
CHANGED