From 3c2c8bcad090953666f505ef4798bf563ae268e8 Mon Sep 17 00:00:00 2001 From: Amp Date: Tue, 13 Oct 2026 15:34:39 +0000 Subject: [PATCH] release: 3.2.0 Chrome Web Store readiness Fix three extension pages that failed in a real MV3 load: the Library imported vendor helpers from a nonexistent src/vendor/, and share.html and whats-new.html ran inline scripts the extension CSP blocks (plus an inline onsubmit on the Ask bar that let Enter reload the Library). Drop the tabs and webNavigation permissions and the unused claude.ai, grok.com, and pypi.org/pypi hosts. The ChatGPT loopback callback is read via tabs.onUpdated with the existing localhost host permission, so the install prompt no longer asks to read browsing history. Add a deterministic, validated store package (npm run package), a release consistency gate (npm run check:release), PRIVACY.md and a website privacy page, a store submission kit with permission justifications and brand assets, an honest config-driven install CTA, an agents (MCP) docs page with a wmcp.sh pointer, a clear OLLAMA_ORIGINS fix for local Ollama 403s, and refreshed lockfiles for new high-severity advisories. Cut 3.2.0 across manifest, package, CHANGELOG, What's New, README, and the website. Amp-Thread-ID: https://ampcode.com/threads/T-01a135b8-65da-7511-9a0e-72a5ad5dff0f Co-authored-by: aisle9angel@gmail.com --- .github/workflows/ci.yml | 12 + .github/workflows/release.yml | 62 ++-- .gitignore | 2 + .prettierignore | 1 + CHANGELOG.md | 36 ++- PRIVACY.md | 71 +++++ README.md | 19 +- docs/store/assets/promo-small-440x280.png | Bin 0 -> 21106 bytes docs/store/assets/store-icon-128.png | Bin 0 -> 6351 bytes docs/store/chrome-web-store.md | 167 +++++++++++ library.html | 2 +- manifest.json | 17 +- mcp/README.md | 7 + mcp/package-lock.json | 36 +-- package-lock.json | 16 +- package.json | 8 +- share.html | 55 +--- src/background.js | 18 +- src/errors.js | 9 +- src/library.js | 8 +- src/share.js | 49 ++++ src/whats-new.js | 58 ++++ tests/errors.test.js | 8 + tests/package-extension.test.js | 151 ++++++++++ tools/check-html.mjs | 5 +- tools/check-release.mjs | 84 ++++++ tools/extension-package.mjs | 327 ++++++++++++++++++++++ tools/package-extension.mjs | 60 ++++ tools/store-assets.mjs | 112 ++++++++ website/components/home/FinalCta.tsx | 8 +- website/components/home/Hero.tsx | 8 +- website/components/site/InstallButton.tsx | 21 ++ website/components/site/SiteFooter.tsx | 7 +- website/components/site/SiteHeader.tsx | 8 +- website/content/docs/agents.mdx | 59 ++++ website/content/docs/getting-started.mdx | 59 +++- website/content/docs/how-it-works.mdx | 2 +- website/content/docs/index.mdx | 2 + website/content/docs/meta.json | 5 +- website/content/docs/models.mdx | 4 +- website/content/docs/privacy.mdx | 53 ++++ website/lib/distribution.ts | 13 + website/lib/releases.ts | 29 +- whats-new.html | 126 ++++----- 44 files changed, 1518 insertions(+), 286 deletions(-) create mode 100644 PRIVACY.md create mode 100644 docs/store/assets/promo-small-440x280.png create mode 100644 docs/store/assets/store-icon-128.png create mode 100644 docs/store/chrome-web-store.md create mode 100644 src/share.js create mode 100644 src/whats-new.js create mode 100644 tests/package-extension.test.js create mode 100644 tools/check-release.mjs create mode 100644 tools/extension-package.mjs create mode 100644 tools/package-extension.mjs create mode 100644 tools/store-assets.mjs create mode 100644 website/components/site/InstallButton.tsx create mode 100644 website/content/docs/agents.mdx create mode 100644 website/content/docs/privacy.mdx create mode 100644 website/lib/distribution.ts 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 +