diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af5f07d..c88a4e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,18 @@ jobs: run: npm run check:html - name: Format check run: npm run format:check + - name: Release consistency + run: npm run check:release + # Builds the exact Chrome Web Store upload and fails on CSP violations, + # unresolved imports, dev files, icon/manifest problems, or permissions + # without a store justification. + - name: Package extension + run: npm run package + - uses: actions/upload-artifact@v4 + with: + name: repolens-extension-zip + path: dist/ + if-no-files-found: error - name: Dependency audit run: npm audit --audit-level=high diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3ddf4d..e4d550c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,8 @@ permissions: jobs: package: runs-on: ubuntu-latest + permissions: + contents: write # attach the zip to the GitHub Release on tag pushes steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 @@ -19,53 +21,35 @@ jobs: node-version: '20' cache: 'npm' - run: npm ci - - name: Verify version matches tag - if: startsWith(github.ref, 'refs/tags/') + - name: Release consistency run: | - VERSION="${GITHUB_REF_NAME#v}" - VERSION="$VERSION" node - <<'NODE' - const fs = require('fs'); - const version = process.env.VERSION; - const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')).version; - const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')).version; - if (pkg !== version || manifest !== version) { - console.error(`Version mismatch: tag=${version} package=${pkg} manifest=${manifest}`); - process.exit(1); - } - NODE + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + npm run check:release -- --tag "$GITHUB_REF_NAME" + else + npm run check:release + fi - name: Verify run: | npm test npm run lint npm run check:html + npm run format:check npm audit --audit-level=high + # Deterministic, validated build — the same zip goes to the Chrome Web Store. - name: Package Chrome extension - run: | - VERSION=$(node -p "require('./manifest.json').version") - ZIP="dist/repolens-v${VERSION}.zip" - rm -rf dist repolens-release - mkdir -p dist repolens-release - - cp manifest.json README.md CHANGELOG.md repolens-release/ - cp batch.html library.html options.html output-tab.html share.html stack-tab.html whats-new.html repolens-release/ - cp ./*.css repolens-release/ - cp -R src icons assets vendor repolens-release/ - (cd repolens-release && zip -r "../${ZIP}" . -x '*.DS_Store') - - unzip -Z1 "$ZIP" | sort > /tmp/repolens-zip-files.txt - cat /tmp/repolens-zip-files.txt - forbidden='(^|/)(node_modules|tests|coverage|website|mcp|docs|tools|\.github|\.git|\.verify|\.playwright-mcp)/|(^|/)(eslint\.config\.js|vitest\.config\.js|package-lock\.json|package\.json)$' - if grep -E "$forbidden" /tmp/repolens-zip-files.txt; then - echo "Release zip contains forbidden dev/test files" >&2 - exit 1 - fi - for required in manifest.json src/background.js output-tab.html library.html whats-new.html src/store/idb.js icons/icon128.png; do - if ! grep -Fxq "$required" /tmp/repolens-zip-files.txt; then - echo "Release zip missing required file: $required" >&2 - exit 1 - fi - done + run: npm run package - uses: actions/upload-artifact@v4 with: name: repolens-extension-zip - path: dist/*.zip + path: dist/ + if-no-files-found: error + - name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + node tools/check-release.mjs --notes > release-notes.md + gh release create "$GITHUB_REF_NAME" dist/*.zip dist/*.sha256 \ + --title "RepoLens $GITHUB_REF_NAME" \ + --notes-file release-notes.md \ + --verify-tag diff --git a/.gitignore b/.gitignore index aeb294f..195841b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage/ # playwright/chrome-devtools MCP scratch output .playwright-mcp/ *-screenshot.png +dist/ +.store-assets-profile/ diff --git a/.prettierignore b/.prettierignore index f821ef3..88fa4ff 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,4 @@ package-lock.json *.min.js icons vendor +dist diff --git a/CHANGELOG.md b/CHANGELOG.md index cfafaaf..ffbd3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,26 +7,48 @@ This project follows [Semantic Versioning](https://semver.org/) and groups chang by theme. Dates are when the release landed on `main`. 1.1.0 through 1.6.0 shipped the same day, as a rapid burst of improvements, so they share a date. -## [Unreleased] — 2026-06-25 · _Subscription Auth Fix · Codex Responses API_ +## [3.2.0] — 2026-10-13 · _Store Ready · Review Queue · Free Scans_ + +The first build packaged for the Chrome Web Store, plus the review and free-model +work that landed after 3.1.1. ### Added - **Review Queue in Library.** One toggle narrows the Library to pending **Trial** / **Hold** adoption decisions and scans older than 14 days. It composes with existing search, collections, decisions, exports, and sorts, making a periodic review session a focused list instead of a manual hunt. - **Cost-aware model defaults.** New OpenRouter connections start on `z-ai/glm-5.2:free`; local Ollama starts on `llama3.2:3b`, a reliable JSON-capable local model. Qwen3 4B/8B remain available for deeper local reasoning. Existing saved model selections are unchanged. +- **Chrome Web Store package.** `npm run package` builds `dist/repolens-v.zip` deterministically (sorted entries, pinned timestamps, byte-identical rebuilds) with a `.sha256`, and refuses to build if the package would ship a broken page: inline scripts or handlers the MV3 CSP blocks, imports or page URLs that don't resolve, wrong icon sizes, manifest store limits, dev files, or a permission without a store justification. Tag pushes attach the zip to a GitHub Release. +- **Release consistency gate.** `npm run check:release` fails when the manifest, package, lockfile, CHANGELOG, What's New, README, or website disagree on the current version. +- **Privacy policy and store listing kit.** A plain-language [privacy policy](PRIVACY.md) (also on the website) and `docs/store/chrome-web-store.md` with listing copy, permission justifications, data-use disclosures, and the asset checklist. +- **MCP: goal-aware evaluation and product analysis.** `evaluate_for_goal` scores a repo against a concrete goal, and `analyze_product` inspects a public product URL (private and loopback hosts are refused). + +### Changed + +- **Fewer permissions.** RepoLens no longer requests `tabs` or `webNavigation`, so the install prompt no longer says it can read your browsing history. Unused host access to `claude.ai`, `grok.com`, and the redundant `pypi.org/pypi/*` was removed. ChatGPT sign-in now reads its loopback callback through `tabs.onUpdated` and the existing `localhost` permission. +- **Website install path is honest.** "Add to Chrome" buttons now say **Install for Chrome** and lead to a step-by-step install guide (release zip or source) until the Web Store listing is live; one setting flips them to the store link. ### Fixed +- **Library page loads again.** It imported its animation helpers from `src/vendor/`, which hasn't existed since the source tree moved into `src/`, so the whole Library script failed to load. It now imports from `vendor/`. +- **Share card and What's New pages run their scripts.** Both used inline ` + diff --git a/src/background.js b/src/background.js index 195c43b..470bc79 100644 --- a/src/background.js +++ b/src/background.js @@ -603,16 +603,17 @@ function resolveCompetitor(input) { return { platform: 'github', repoId }; } -// One redirect can fire BOTH a navigation event and tabs.onUpdated, and the auth -// code is single-use — this de-dups so only the first handler runs the exchange. +// tabs.onUpdated can report the same callback URL more than once, and the auth +// code is single-use — this de-dups so only the first event runs the exchange. const _handledOAuthCodes = new Set(); // ─── OpenAI OAuth callback handling ("Sign in with ChatGPT", Codex CLI flow) ─── // // The redirect lands on http://localhost:1455/auth/callback — the loopback server -// the CLI runs doesn't exist in a browser, so the navigation can't complete. We -// intercept it (onBeforeNavigate fires first, with the ?code=), exchange the code -// for OAuth credentials. Inference uses the Codex Responses API with the access +// the CLI runs doesn't exist in a browser, so the navigation can't complete. The +// failed page still commits its URL, and the http://localhost/* host permission +// lets tabs.onUpdated see it (no `tabs`/`webNavigation` permission needed). We +// read the ?code= from it and exchange it for OAuth credentials. Inference uses the Codex Responses API with the access // token directly — no API key minting needed. async function handleOpenAIOAuthCallback(rawUrl, tabId) { @@ -673,13 +674,6 @@ async function handleOpenAIOAuthCallback(rawUrl, tabId) { } } -// The OpenAI loopback redirect can't load (no local server), so onCompleted never -// fires for it — onBeforeNavigate runs first and still carries the ?code=. -chrome.webNavigation.onBeforeNavigate.addListener((details) => { - if (details.frameId !== 0) return; - handleOpenAIOAuthCallback(details.url, details.tabId); -}); - chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { if (changeInfo.url) { handleOpenAIOAuthCallback(changeInfo.url, tabId); diff --git a/src/errors.js b/src/errors.js index 3af7741..7f3d83d 100644 --- a/src/errors.js +++ b/src/errors.js @@ -32,12 +32,17 @@ function statusOf(err) { return m ? Number(m[1]) : 0; } -function humanize(kind, provider, fallback) { +function humanize(kind, provider, fallback, status) { const who = provider || 'This provider'; switch (kind) { case 'none': return 'No AI provider connected — open Settings to add a key.'; case 'auth': { + // Local Ollama has no credential: a 403 there is its origin check refusing + // chrome-extension:// requests, which only OLLAMA_ORIGINS fixes. + if (status === 403 && /^ollama \(local\)$/i.test(who)) { + return 'Ollama (Local) refused the request from the extension. Quit Ollama, start it again with OLLAMA_ORIGINS=chrome-extension://* set, then retry.'; + } // Surface the provider's actual reason when we have one — the generic line // hides *why* (e.g. an OAuth subscription token refused at inference time, // which reads very differently from a mistyped API key). @@ -100,7 +105,7 @@ export function categorizeError(err, provider = '') { retryable: meta.retryable, fixable: meta.fixable, priority: meta.priority, - userMessage: humanize(kind, provider, msg), + userMessage: humanize(kind, provider, msg, status), }; } diff --git a/src/library.js b/src/library.js index 2b7acee..fe27096 100644 --- a/src/library.js +++ b/src/library.js @@ -82,9 +82,9 @@ import { } from './evaluations.js'; import { applyFilters } from './library-filters.js'; // Vendored animation libs (local ES modules — never CDN; the MV3 CSP forbids remote scripts). -import confetti from './vendor/confetti.mjs'; -import { autoAnimate } from './vendor/auto-animate.mjs'; -import { CountUp } from './vendor/countup.mjs'; +import confetti from '../vendor/confetti.mjs'; +import { autoAnimate } from '../vendor/auto-animate.mjs'; +import { CountUp } from '../vendor/countup.mjs'; // Honour the user's chosen theme on this standalone page (sets ). initTheme(); @@ -3697,6 +3697,8 @@ async function init() { const askInput = document.getElementById('ask-input'); const askBtn = document.getElementById('ask-btn'); const doAsk = () => submitAsk(askInput?.value); + // The ask bar is a
; MV3 CSP blocks inline handlers, so stop implicit submit here. + document.getElementById('ask-bar')?.addEventListener('submit', (e) => e.preventDefault()); askBtn?.addEventListener('click', doAsk); askInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') doAsk(); diff --git a/src/share.js b/src/share.js new file mode 100644 index 0000000..162aa38 --- /dev/null +++ b/src/share.js @@ -0,0 +1,49 @@ +// Share-card page: decodes the verdict card from the URL hash and renders it. +// Lives in its own file because MV3 extension pages block inline scripts. +import { decodeShareCard } from './share-card.js'; + +const esc = (s) => + String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +const fmtStars = (n) => + n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + 'M' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); + +const card = document.getElementById('card'); +const data = decodeShareCard(location.hash); + +if (!data) { + card.innerHTML = `
+

Invalid share link

+

This verdict card link is missing or corrupted.

+
`; +} else { + const fitLabel = + { strong: 'Strong fit', solid: 'Solid', care: 'Use with care', risky: 'Risky' }[data.fitLevel] || 'Solid'; + const pills = [ + data.language ? `${esc(data.language)}` : '', + data.stars ? `${esc(fmtStars(data.stars))} ★` : '', + data.license ? `${esc(data.license)}` : '', + data.health ? `Health ${data.health}` : '', + ] + .filter(Boolean) + .join(''); + + card.innerHTML = ` +
+
🔍
+ REPOLENS VERDICT +
+
${esc(data.repoId)}
+${data.description ? `

${esc(data.description)}

` : ''} +${esc(fitLabel)} +${data.eli5 ? `

${esc(data.eli5)}

` : ''} +${pills ? `
${pills}
` : ''} + + `; + document.title = `RepoLens — ${data.repoId}`; +} diff --git a/src/whats-new.js b/src/whats-new.js new file mode 100644 index 0000000..a6c797a --- /dev/null +++ b/src/whats-new.js @@ -0,0 +1,58 @@ +// What's New page: Vee state demo + scroll reveals. +// Lives in its own file because MV3 extension pages block inline scripts. +const SAYS = { + scanning: 'Reading the source. This is the boring, important part.', + strong: "Looked hard. Couldn't find the catch. Rare.", + risky: 'Eyes narrowed. The README and the code disagree.', + thinking: 'Tracing how the pieces actually fit together…', + empty: 'Nothing to look at yet. Point me at a repo.', +}; + +const VEE_SVG = ` + + + + + + + + + `; +document.querySelectorAll('.vee').forEach((el) => { + el.innerHTML = VEE_SVG; +}); + +const STATES = ['scanning', 'strong', 'risky', 'thinking', 'empty']; +const setState = (el, s) => { + STATES.forEach((x) => el.classList.toggle('is-' + x, x === s)); +}; + +const demoVee = document.getElementById('demoVee'); +const veeSay = document.getElementById('veeSay'); +document.getElementById('veeBtns').addEventListener('click', (e) => { + const btn = e.target.closest('.vbtn'); + if (!btn) return; + const s = btn.dataset.state; + document.querySelectorAll('#veeBtns .vbtn').forEach((b) => b.classList.toggle('on', b === btn)); + setState(demoVee, s); + veeSay.textContent = SAYS[s] || ''; +}); + +const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; +const items = document.querySelectorAll('.reveal'); +if (reduce || !('IntersectionObserver' in window)) { + items.forEach((el) => el.classList.add('in')); +} else { + const io = new IntersectionObserver( + (entries) => { + entries.forEach((en) => { + if (en.isIntersecting) { + en.target.classList.add('in'); + io.unobserve(en.target); + } + }); + }, + { threshold: 0.12 } + ); + items.forEach((el) => io.observe(el)); +} diff --git a/tests/errors.test.js b/tests/errors.test.js index 752878b..649d346 100644 --- a/tests/errors.test.js +++ b/tests/errors.test.js @@ -29,6 +29,14 @@ describe('categorizeError', () => { expect(r.userMessage).toMatch(/reconnect it in Settings/i); expect(r.userMessage).not.toMatch(/API error/i); }); + it('tells local Ollama users how to allow the extension origin on a 403', () => { + // Ollama answers chrome-extension:// origins with a bare 403 unless OLLAMA_ORIGINS allows them. + const r = categorizeError(new Error('Ollama (Local) API error 403'), 'Ollama (Local)'); + expect(r.kind).toBe('auth'); + expect(r.userMessage).toMatch(/OLLAMA_ORIGINS=chrome-extension:\/\/\*/); + // A real credential rejection elsewhere keeps the reconnect guidance. + expect(categorizeError({ status: 403 }, 'Ollama Cloud').userMessage).toMatch(/reconnect it in Settings/i); + }); it('classifies rate limits as retryable', () => { expect(categorizeError(new Error('429 Too Many Requests')).kind).toBe('rate_limit'); expect(categorizeError('rate limit exceeded').retryable).toBe(true); diff --git a/tests/package-extension.test.js b/tests/package-extension.test.js new file mode 100644 index 0000000..dc96237 --- /dev/null +++ b/tests/package-extension.test.js @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { inflateRawSync } from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { + buildZip, + collectPackageFiles, + listZip, + readerFor, + validatePackage, +} from '../tools/extension-package.mjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); + +// Minimal 1×1-pixel-header PNG with the given dimensions (only IHDR is read). +const png = (size) => { + const b = Buffer.alloc(24); + Buffer.from('89504e470d0a1a0a', 'hex').copy(b); + b.writeUInt32BE(size, 16); + b.writeUInt32BE(size, 20); + return b; +}; + +function fixture(overrides = {}) { + const icons = { 16: 'icons/16.png', 32: 'icons/32.png', 48: 'icons/48.png', 128: 'icons/128.png' }; + const manifest = { + manifest_version: 3, + name: 'Fixture', + version: '1.2.3', + description: 'Fixture extension.', + permissions: ['storage'], + host_permissions: ['https://api.github.com/*'], + background: { service_worker: 'src/bg.js', type: 'module' }, + options_page: 'options.html', + action: { default_icon: icons }, + icons, + }; + const tree = { + 'manifest.json': JSON.stringify(manifest), + 'options.html': '', + 'src/bg.js': "import { x } from './lib.js';\nchrome.tabs.create({ url: 'options.html' });", + 'src/lib.js': 'export const x = 1;', + 'src/options.js': "import confetti from '../vendor/confetti.mjs';", + 'vendor/confetti.mjs': 'export default () => {};', + 'icons/16.png': png(16), + 'icons/32.png': png(32), + 'icons/48.png': png(48), + 'icons/128.png': png(128), + ...overrides, + }; + for (const [k, v] of Object.entries(tree)) if (v === undefined) delete tree[k]; + return { + files: Object.keys(tree).sort(), + read: (p) => Buffer.from(tree[p]), + packageVersion: '1.2.3', + permissionsDoc: '`storage` `https://api.github.com/*`', + }; +} + +describe('validatePackage', () => { + it('accepts a well-formed package', () => { + expect(validatePackage(fixture())).toEqual([]); + }); + + it.each([ + ['inline script', { 'options.html': '' }, /inline +