diff --git a/.github/workflows/generate-readme.yml b/.github/workflows/generate-readme.yml index 1d4d9a1..ed16ca3 100644 --- a/.github/workflows/generate-readme.yml +++ b/.github/workflows/generate-readme.yml @@ -37,7 +37,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Generate and push README/registry run: | diff --git a/.github/workflows/health.yml b/.github/workflows/health.yml index d3b3787..8d3f41c 100644 --- a/.github/workflows/health.yml +++ b/.github/workflows/health.yml @@ -33,7 +33,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Check health and open bot PRs env: diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml index 64c0777..e1878ab 100644 --- a/.github/workflows/issues.yml +++ b/.github/workflows/issues.yml @@ -11,10 +11,7 @@ on: types: [opened, edited, reopened, labeled] permissions: - contents: write - pull-requests: write - issues: write - actions: write + contents: read concurrency: group: issue-${{ github.event.issue.number }} @@ -23,6 +20,11 @@ concurrency: jobs: submission: if: contains(github.event.issue.labels.*.name, 'submission') && github.event.issue.state == 'open' + permissions: + contents: write + pull-requests: write + issues: write + actions: write runs-on: ubuntu-latest steps: - name: Checkout @@ -37,7 +39,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Build entry and open PR env: @@ -47,6 +49,11 @@ jobs: scout-accept: if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'scout') + permissions: + contents: write + pull-requests: write + issues: write + actions: write runs-on: ubuntu-latest steps: - name: Checkout @@ -61,7 +68,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Open PRs for ticked candidates env: diff --git a/.github/workflows/reprobe.yml b/.github/workflows/reprobe.yml index 12f2956..d034350 100644 --- a/.github/workflows/reprobe.yml +++ b/.github/workflows/reprobe.yml @@ -37,7 +37,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Re-check held PRs env: diff --git a/.github/workflows/scout.yml b/.github/workflows/scout.yml index 9ee4ea1..25dd9bf 100644 --- a/.github/workflows/scout.yml +++ b/.github/workflows/scout.yml @@ -28,7 +28,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Find candidates env: diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 5627ac0..1e0d16b 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -15,8 +15,6 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: group: pages @@ -38,7 +36,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Build site env: @@ -58,6 +56,9 @@ jobs: deploy: needs: build + permissions: + pages: write + id-token: write runs-on: ubuntu-latest environment: name: github-pages diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index d513a25..81ef67f 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -34,7 +34,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Label and comment env: diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 967e171..a34065b 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -32,7 +32,7 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Unit tests run: npm test diff --git a/scripts/generate-readme.js b/scripts/generate-readme.js index 26928f8..b00157f 100644 --- a/scripts/generate-readme.js +++ b/scripts/generate-readme.js @@ -9,7 +9,7 @@ const { CATEGORIES, KINDS, KIND_LABELS, TOPICS, TOPIC_LABELS, V1_SUNSET, checkCatalog, formatProblems, loadCatalog } = require('./utils/catalog'); const { addedDates } = require('./utils/history'); -const { escapeHtml, readTemplate, replacePlaceholder, generateEntryHtml, writeReadme } = require('./utils/template'); +const { escapeCell, escapeHtml, readTemplate, replacePlaceholder, generateEntryHtml, writeReadme } = require('./utils/template'); const RECENT_COUNT = 15; const README_WARN_KB = 450; @@ -72,7 +72,7 @@ function generateRecent(entries) { .filter(e => dates.has(e.file)) .sort((a, b) => dates.get(b.file).localeCompare(dates.get(a.file)) || byName(a, b)) .slice(0, RECENT_COUNT); - const rows = recent.map(e => `| **[${escapeHtml(e.data.name).replace(/\|/g, '\\|')}](${e.data.repo})** | ${e.category} | ${escapeHtml(e.data.tagline).replace(/\|/g, '\\|')} |`); + const rows = recent.map(e => `| **[${escapeCell(escapeHtml(e.data.name))}](${e.data.repo})** | ${e.category} | ${escapeCell(escapeHtml(e.data.tagline))} |`); return ['| Name | Category | Tagline |', '| :--- | :--- | :--- |', ...rows].join('\n'); } diff --git a/scripts/utils/github.js b/scripts/utils/github.js index 0181c6a..8eb3132 100644 --- a/scripts/utils/github.js +++ b/scripts/utils/github.js @@ -79,6 +79,9 @@ async function graphql(query, variables = {}) { */ async function rest(route, { method = 'GET', body } = {}) { const url = route.startsWith('http') ? route : `https://api.github.com${route}`; + if (!url.startsWith('https://api.github.com/') || /\/\.{1,2}(\/|\?|$)/.test(url.split('?')[0])) { + throw new Error(`refusing to call ${url}`); + } const res = await fetchWithRetry(url, { method, headers: headers(body ? { 'Content-Type': 'application/json' } : {}), @@ -142,8 +145,11 @@ async function getFiles(requests, { batchSize = 60 } = {}) { * @param {string} pkg * @returns {Promise} null when the package does not exist */ +const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; + async function getNpmLatest(pkg) { - const res = await fetchWithRetry(`https://registry.npmjs.org/${pkg.replace('/', '%2F')}/latest`, { + if (!NPM_NAME_RE.test(pkg)) return null; + const res = await fetchWithRetry(`https://registry.npmjs.org/${pkg.replace(/\//g, '%2F')}/latest`, { headers: { 'User-Agent': 'awesome-opencode-bot', Accept: 'application/json' } }); if (res.status === 404) return null; @@ -157,6 +163,7 @@ async function getNpmLatest(pkg) { * @returns {Promise} */ async function getNpmWeeklyDownloads(pkg) { + if (!NPM_NAME_RE.test(pkg)) return 0; const res = await fetchWithRetry(`https://api.npmjs.org/downloads/point/last-week/${pkg}`, { headers: { 'User-Agent': 'awesome-opencode-bot' } }); diff --git a/scripts/utils/pr-checks.js b/scripts/utils/pr-checks.js index e0a5bc3..f03950d 100644 --- a/scripts/utils/pr-checks.js +++ b/scripts/utils/pr-checks.js @@ -10,6 +10,7 @@ const yaml = require('js-yaml'); const { CATEGORIES, canonicalKey, checkCatalog, parseRepoUrl } = require('./catalog'); const { probeCompat } = require('./compat'); const { getRepos } = require('./github'); +const { escapeCell } = require('./template'); // Generated files a PR must never edit by hand const GENERATED_FILES = ['README.md', 'dist/registry.json']; @@ -158,7 +159,7 @@ function formatReport(result) { const verified = result.entries.filter(e => e.probe); if (verified.length) { lines.push('', '**V2 compatibility**', '', '| Entry | Detected | Evidence |', '| :--- | :--- | :--- |'); - for (const e of verified) lines.push(`| \`${path.basename(e.file)}\` | ${e.probe.compat} | ${e.probe.evidence.join('; ').replace(/\|/g, '\\|') || 'โ€”'} |`); + for (const e of verified) lines.push(`| \`${path.basename(e.file)}\` | ${e.probe.compat} | ${escapeCell(e.probe.evidence.join('; ')) || 'โ€”'} |`); } lines.push('', 'Entry format: [contributing.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/contributing.md) ยท Catalog policy: [docs/policy.md](https://github.com/Heretek-AI/awesome-opencode/blob/main/docs/policy.md)'); return lines.join('\n'); diff --git a/scripts/utils/template.js b/scripts/utils/template.js index 5d528ef..58a1595 100644 --- a/scripts/utils/template.js +++ b/scripts/utils/template.js @@ -45,6 +45,34 @@ function escapeHtml(text) { .replace(/"/g, '"'); } +/** + * Escape text for a markdown table cell. + * @param {string} text + * @returns {string} + */ +function escapeCell(text) { + return String(text).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +/** + * Parse a URL, or null when it is not one. + * @param {string} url + * @returns {URL|null} + */ +function parseUrl(url) { + try { return new URL(url); } catch { return null; } +} + +/** + * True for npm package pages (they duplicate the npm link, so they are not shown as docs). + * @param {string} url + * @returns {boolean} + */ +function isNpmPage(url) { + const u = parseUrl(url); + return Boolean(u && (u.hostname === 'www.npmjs.com' || u.hostname === 'npmjs.com') && u.pathname.startsWith('/package/')); +} + /** * URL of the entry's code, including the monorepo subfolder when set. * @param {object} entry - Parsed YAML data @@ -61,19 +89,20 @@ function sourceUrl(entry) { * @returns {string} HTML string for the details element */ function generateEntryHtml(entry, category) { + const url = parseUrl(entry.repo); + const isGist = Boolean(url && url.hostname === 'gist.github.com'); + const isDiscussion = Boolean(url && url.hostname === 'github.com' && /^\/[^/]+\/[^/]+\/discussions\//.test(url.pathname)); + // Determine link text based on URL type first let linkText = '๐Ÿ”— View Repository'; - if (entry.repo.includes('gist.github.com')) { + if (isGist) { linkText = '๐Ÿ”— View Gist'; - } else if (entry.repo.includes('/discussions/')) { + } else if (isDiscussion) { linkText = '๐Ÿ”— View Discussion'; } // Extract owner/repo from URL for star badge (only for non-gist, non-discussion GitHub repos) - // Use negative lookahead to exclude gist.github.com - const isGist = entry.repo.includes('gist.github.com'); - const isDiscussion = entry.repo.includes('/discussions/'); - const repoMatch = entry.repo.match(/github\.com\/(?!gist\.)([^\/]+)\/([^\/]+)/); + const repoMatch = url && url.hostname === 'github.com' ? url.pathname.match(/^\/([^/]+)\/([^/]+)/) : null; let summaryContent = `${escapeHtml(entry.name)}`; @@ -100,7 +129,7 @@ function generateEntryHtml(entry, category) { if (entry.npm) { links.push(`๐Ÿ“ฆ npm`); } - if (entry.homepage && !/npmjs\.com\/package\//.test(entry.homepage)) { + if (entry.homepage && !isNpmPage(entry.homepage)) { links.push(`๐Ÿ“– Docs`); } @@ -124,7 +153,9 @@ function writeReadme(content) { } module.exports = { + escapeCell, escapeHtml, + isNpmPage, readTemplate, replacePlaceholder, generateEntryHtml, diff --git a/site/index.html b/site/index.html index a05cd32..3a51351 100644 --- a/site/index.html +++ b/site/index.html @@ -106,6 +106,7 @@ const TOPIC_LABELS = { memory: 'Memory', context: 'Context & tokens', orchestrat const STATUS_LABELS = { quiet: 'quiet', stale: 'no push in 1y', archived: 'archived', missing: 'repo missing', deprecated: 'deprecated' }; const DEAD = ['archived', 'missing', 'deprecated']; const $ = id => document.getElementById(id); +const isNpmPage = url => { try { const u = new URL(url); return (u.hostname === 'www.npmjs.com' || u.hostname === 'npmjs.com') && u.pathname.startsWith('/package/'); } catch { return false; } }; const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const state = { q: '', cat: '', kind: '', topic: '', compat: '', status: '', sort: 'name' }; let entries = []; @@ -155,7 +156,7 @@ function card(e) { const src = e.repoPath ? `${e.repoUrl.replace(/\/+$/, '')}/tree/HEAD/${e.repoPath}` : e.repoUrl; const links = [`Repository`]; if (e.npmPackage) links.push(`npm`); - if (e.homepageUrl && !/npmjs\.com\/package\//.test(e.homepageUrl)) links.push(`Docs`); + if (e.homepageUrl && !isNpmPage(e.homepageUrl)) links.push(`Docs`); if (e.pushedAt) links.push(`updated ${esc(e.pushedAt.slice(0, 10))}`); const long = (e.description || '').length > 180; return `
diff --git a/test/catalog.test.js b/test/catalog.test.js index f572314..5699f9e 100644 --- a/test/catalog.test.js +++ b/test/catalog.test.js @@ -73,6 +73,6 @@ test('README entries escape contributor text and show compat badges', () => { assert.match(html, /<prompt>/); assert.match(html, /V1 only<\/code>/); assert.match(html, /tree\/HEAD\/pkg\/x/); - assert.match(html, /npmjs\.com\/package\/thing/); + assert.ok(html.includes('href="https://www.npmjs.com/package/thing"')); assert.doesNotMatch(generateEntryHtml({ ...base, compat: 'n/a' }, 'projects'), //); });