From 0db616b35b4dbfcaf2259c058a9520f4f26ebd6e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 03:07:13 +0000 Subject: [PATCH] Add gh-pulse, the daily GitHub movement + traffic email Every repo the gh token can see (own + every org, forks excluded) is checked for movement since the previous run: stars, forks, commits, PRs, issues, releases, and the /graphs/traffic numbers (views, unique visitors, clones, referrers, popular content). Movers are ranked by a weighted score and emailed through Resend with 14-day charts (SVG rendered to PNG with @resvg/resvg-js, inline via cid), and new/lost followers are named. GitHub publishes traffic in UTC-day buckets one to two days late, so the window is the growth of each bucket since the previous snapshot rather than a clock: nothing is missed or double counted when a run is skipped. Every run writes a gzipped snapshot of the raw counts and buckets to ~/.local/share/gh-pulse/snapshots; GitHub keeps nothing past 14 days, so those files are the long-run history. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011gfb1vcQH5rf82HfbJvf1Z --- README.md | 39 +++ bin/gh-pulse | 618 +++++++++++++++++++++++++++++++++ lib/gh-pulse/.gitignore | 1 + lib/gh-pulse/package-lock.json | 240 +++++++++++++ lib/gh-pulse/package.json | 8 + 5 files changed, 906 insertions(+) create mode 100755 bin/gh-pulse create mode 100644 lib/gh-pulse/.gitignore create mode 100644 lib/gh-pulse/package-lock.json create mode 100644 lib/gh-pulse/package.json diff --git a/README.md b/README.md index 95b0998..583db25 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,44 @@ gh-issues --orgs profullstack --csv=/tmp/issues.csv Bare `--csv` takes no argument on purpose, so `--csv --limit 10` cannot swallow the next flag as a filename. +### `gh-pulse` + +The daily "what moved on GitHub" email, with charts. Every repo the `gh` token +can see (yours plus every org you belong to, forks excluded) is checked for +movement since the previous run: stars, forks, commits, pull requests, issues, +releases, and the traffic GitHub shows at `/graphs/traffic` (views, unique +visitors, clones, referrers, popular content). Repos with movement are ranked +by a weighted score; each one comes with its traffic, the top ones with a +14-day chart, and new or lost followers are named. + +```sh +gh-pulse # scan, email, snapshot +gh-pulse --dry-run # scan and write the HTML, send nothing +gh-pulse --top 12 # how many ranked repos get a chart and detail +gh-pulse --repo profullstack/nixamp # only this repo (repeatable) +``` + +Movement is measured against the previous snapshot rather than a clock, +because GitHub publishes traffic in UTC-day buckets one to two days late: +each run counts the growth of the 14-day buckets since the last run, so a +skipped day is neither lost nor double counted (the header says how long the +window was). Every run writes `~/.local/share/gh-pulse/snapshots/.json.gz` +with the raw per-repo counts and traffic buckets; GitHub keeps nothing past 14 +days, so those files are the long-run history. `out/latest.html`, `.txt` and +`.json` next to them are the last report. + +Mail goes through Resend: `RESEND_API_KEY` from the environment, or from +`~/.config/logicsrc/shell.env` when unset (the cron case). The from address +must sit on a verified Resend domain; `GH_PULSE_FROM` overrides it and +`GH_PULSE_TO` (or `--to`) the recipient. The chart rasteriser is a small npm +dependency in `lib/gh-pulse/`, installed on first run. + +A daily cron entry looks like: + +``` +5 13 * * * /home/anthony/scripts/bin/gh-pulse >>/home/anthony/.local/share/gh-pulse/cron.log 2>&1 +``` + ### `gh-prs-merge` Walks the same scopes and squash-merges every PR that qualifies, oldest first. @@ -315,6 +353,7 @@ an installer is unavailable and you need the old contents back. `gh` (authenticated), `jq`, and `awk`. `provision-ssh-keys` needs only OpenSSH (`ssh`, `ssh-keyscan`, `ssh-keygen`) locally and `bash` on the remote host. +`gh-pulse` wants `node` 20+ and `npm` (once, for its chart dependency). `domainjson` additionally wants `node`, `dig`, and the OpenRDAP CLI (`go install github.com/openrdap/rdap/cmd/rdap@latest`, run from `~/go/bin/rdap` or on `PATH`). `ssh-logins` needs `journalctl` and diff --git a/bin/gh-pulse b/bin/gh-pulse new file mode 100755 index 0000000..d606009 --- /dev/null +++ b/bin/gh-pulse @@ -0,0 +1,618 @@ +#!/usr/bin/env node +// gh-pulse — the daily "what moved on GitHub" email, with charts. +// +// Every repo the gh token can see (your own plus every org you belong to, +// forks excluded) is checked for movement since the previous run: stars, +// forks, commits, pull requests, issues, releases, and the traffic numbers +// GitHub shows at /graphs/traffic (views, unique visitors, clones, referrers, +// popular content). Repos with movement are ranked by a weighted score and +// emailed with their traffic, and the top ones get a 14-day chart. +// +// Movement is measured against the previous snapshot, not against a fixed +// clock: GitHub only hands out traffic in UTC-day buckets, so "the last 24 +// hours" is computed as the growth of each bucket since the last run. That +// makes the window exactly "since the previous email", which is what you +// actually want to read, and it stays correct if a run is skipped (the +// header then says how long the window was). Follower changes work the same +// way, from a stored list of logins, so new and lost followers are named. +// +// Every run writes a gzipped snapshot under the data dir. That file is the +// history: per-repo counts and the raw 14-day traffic buckets, dated. Nothing +// else on GitHub keeps traffic past 14 days, so the snapshots are the only +// long-run record and are worth keeping (and ingesting elsewhere). +// +// gh-pulse scan, email, snapshot +// gh-pulse --dry-run scan and write the HTML, send nothing, keep +// the previous snapshot as the baseline +// gh-pulse --to a@b.c recipient (default GH_PULSE_TO or the git email) +// gh-pulse --top 12 how many ranked repos get a chart + detail +// gh-pulse --repo owner/name only this repo (repeatable; for testing) +// gh-pulse --hours 24 window when there is no previous snapshot +// +// Mail goes out through Resend (RESEND_API_KEY; ~/.config/logicsrc/shell.env +// is read when the variable is unset, which is the cron case). The from +// address must be on a verified Resend domain; GH_PULSE_FROM overrides it. +// Data dir: $GH_PULSE_DATA or ~/.local/share/gh-pulse. + +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import zlib from 'node:zlib'; + +process.env.PATH = [ + path.join(os.homedir(), '.local/bin'), + path.join(os.homedir(), '.local/share/mise/shims'), + '/usr/local/bin', '/usr/bin', '/bin', process.env.PATH || '', +].join(':'); + +const HERE = path.dirname(fs.realpathSync(fileURLToPath(import.meta.url))); +const ROOT = path.resolve(HERE, '..'); +const DEPS = path.join(ROOT, 'lib', 'gh-pulse'); +const DATA = process.env.GH_PULSE_DATA || path.join(os.homedir(), '.local/share/gh-pulse'); +const SNAPS = path.join(DATA, 'snapshots'); +const OUT = path.join(DATA, 'out'); + +// ------------------------------------------------------------------ args +const args = process.argv.slice(2); +const opt = { dryRun: false, to: process.env.GH_PULSE_TO || '', top: 12, repos: [], hours: 24 }; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--dry-run') opt.dryRun = true; + else if (a === '--to') opt.to = args[++i]; + else if (a === '--top') opt.top = Number(args[++i]); + else if (a === '--hours') opt.hours = Number(args[++i]); + else if (a === '--repo') opt.repos.push(args[++i]); + else if (a === '-h' || a === '--help') { console.log(usage()); process.exit(0); } + else { console.error(`gh-pulse: unknown argument ${a}\n${usage()}`); process.exit(2); } +} +function usage() { + return 'usage: gh-pulse [--dry-run] [--to addr] [--top N] [--hours N] [--repo owner/name]...'; +} + +// ------------------------------------------------------------------ env +function loadShellEnv() { + // cron gets no environment; the house vault export is the fallback. + const f = path.join(os.homedir(), '.config/logicsrc/shell.env'); + if (!fs.existsSync(f)) return; + for (const line of fs.readFileSync(f, 'utf8').split('\n')) { + const m = line.match(/^(?:export\s+)?([A-Z0-9_]+)=(.*)$/); + if (!m || process.env[m[1]]) continue; + process.env[m[1]] = m[2].trim().replace(/^["']|["']$/g, ''); + } +} +if (!process.env.RESEND_API_KEY) loadShellEnv(); + +function ghToken() { + try { return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim(); } + catch { throw new Error('gh is not logged in (gh auth token failed)'); } +} +if (!opt.to) { + try { opt.to = execFileSync('git', ['config', '--get', 'user.email'], { encoding: 'utf8' }).trim(); } catch {} +} +if (!opt.to) { console.error('gh-pulse: no recipient (--to or GH_PULSE_TO)'); process.exit(2); } + +// ------------------------------------------------------------------ deps +async function loadResvg() { + const require = createRequire(import.meta.url); + const modDir = path.join(DEPS, 'node_modules', '@resvg', 'resvg-js'); + if (!fs.existsSync(modDir)) { + console.error('gh-pulse: installing chart dependency (first run)'); + const r = spawnSync('npm', ['install', '--no-audit', '--no-fund', '--loglevel=error'], + { cwd: DEPS, stdio: 'inherit' }); + if (r.status !== 0) throw new Error('npm install of lib/gh-pulse failed'); + } + return require(modDir); +} + +// ------------------------------------------------------------------ github +const API = 'https://api.github.com'; +let TOKEN = ''; +let rateRemaining = null; +const stats = { calls: 0, retries: 0 }; + +function pool(n) { + let active = 0; const q = []; + const next = () => { if (active >= n || !q.length) return; active++; const { fn, res, rej } = q.shift(); + fn().then(res, rej).finally(() => { active--; next(); }); }; + return (fn) => new Promise((res, rej) => { q.push({ fn, res, rej }); next(); }); +} +const limit = pool(6); + +async function gh(pathOrUrl, { accept, allow = [] } = {}) { + const url = pathOrUrl.startsWith('http') ? pathOrUrl : API + pathOrUrl; + for (let attempt = 0; ; attempt++) { + stats.calls++; + const r = await fetch(url, { headers: { + authorization: `Bearer ${TOKEN}`, + accept: accept || 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'user-agent': 'gh-pulse', + } }); + const rem = r.headers.get('x-ratelimit-remaining'); + if (rem !== null) rateRemaining = Number(rem); + if (r.ok) return { data: await r.json(), link: r.headers.get('link') || '' }; + if (allow.includes(r.status)) return { data: null, status: r.status, link: '' }; + const retryable = r.status === 429 || r.status === 502 || r.status === 503 || + (r.status === 403 && (r.headers.get('retry-after') || rem === '0')); + if (retryable && attempt < 4) { + stats.retries++; + let wait = Number(r.headers.get('retry-after') || 0) * 1000; + if (!wait && rem === '0') wait = Math.max(0, Number(r.headers.get('x-ratelimit-reset')) * 1000 - Date.now()) + 1000; + if (!wait) wait = 2000 * (attempt + 1); + await new Promise((s) => setTimeout(s, Math.min(wait, 120000))); + continue; + } + const body = await r.text().catch(() => ''); + throw new Error(`GitHub ${r.status} for ${url}: ${body.slice(0, 200)}`); + } +} +const nextLink = (link) => (link.match(/<([^>]+)>;\s*rel="next"/) || [])[1]; +const lastPage = (link) => Number((link.match(/[?&]page=(\d+)>;\s*rel="last"/) || [])[1] || 1); + +async function ghAll(p, { max = 20, ...o } = {}) { + const out = []; let url = p; + for (let i = 0; url && i < max; i++) { + const { data, link } = await gh(url, o); + if (!Array.isArray(data)) break; + out.push(...data); url = nextLink(link); + } + return out; +} + +// ------------------------------------------------------------------ snapshots +function readSnap(f) { return JSON.parse(zlib.gunzipSync(fs.readFileSync(f)).toString('utf8')); } +function latestSnapshot() { + if (!fs.existsSync(SNAPS)) return null; + const files = fs.readdirSync(SNAPS).filter((f) => f.endsWith('.json.gz')).sort(); + if (!files.length) return null; + const f = path.join(SNAPS, files[files.length - 1]); + try { return { file: f, ...readSnap(f) }; } catch (e) { console.error(`gh-pulse: unreadable snapshot ${f}: ${e.message}`); return null; } +} + +// ------------------------------------------------------------------ scoring +const W = { star: 5, fork: 4, unique: 1, view: 0.2, clone: 0.5, cloner: 1, commit: 1, prOpened: 2, prMerged: 3, prClosed: 0.5, issueOpened: 2, issueClosed: 1, release: 5 }; +function score(m) { + return Math.max(0, m.stars) * W.star + Math.max(0, m.forks) * W.fork + m.uniques * W.unique + m.views * W.view + + m.clones * W.clone + m.cloners * W.cloner + Math.min(m.commits, 25) * W.commit + + m.prOpened * W.prOpened + m.prMerged * W.prMerged + m.prClosed * W.prClosed + + m.issuesOpened * W.issueOpened + m.issuesClosed * W.issueClosed + m.releases * W.release; +} +function isMover(m) { + return m.stars !== 0 || m.forks !== 0 || m.commits > 0 || m.prOpened || m.prMerged || m.prClosed || + m.issuesOpened || m.issuesClosed || m.releases > 0 || m.uniques >= 2 || m.cloners >= 2; +} + +// Growth of the 14-day traffic buckets since the previous snapshot. A bucket +// that exists in both counts only what it gained; a bucket new since last time +// counts in full; a bucket that rolled off is gone and irrelevant. +// GitHub publishes traffic one to two days late (at 03:00 UTC the newest +// bucket is usually the day before yesterday), so without a baseline the +// honest "latest" figure is the newest day GitHub has reported, not a clock +// window that would read as zero. +function trafficDelta(cur, prev, newestDay) { + if (!cur) return { count: 0, uniques: 0 }; + const pm = new Map((prev || []).map((b) => [b.timestamp, b])); + let count = 0, uniques = 0; + for (const b of cur) { + if (prev) { + const p = pm.get(b.timestamp); + count += Math.max(0, b.count - (p ? p.count : 0)); + uniques += Math.max(0, b.uniques - (p ? p.uniques : 0)); + } else if (b.timestamp.slice(0, 10) >= newestDay) { + // No baseline: the last three GitHub days, which covers the lag. + count += b.count; uniques += b.uniques; + } + } + return { count, uniques }; +} +let NEWEST_DAY = new Date().toISOString().slice(0, 10); +function fourteenDays(buckets, endDay = NEWEST_DAY) { + const m = new Map((buckets || []).map((b) => [b.timestamp.slice(0, 10), b])); + const out = []; const d = new Date(endDay + 'T00:00:00Z'); + for (let i = 13; i >= 0; i--) { + const day = new Date(d.getTime() - i * 86400000).toISOString().slice(0, 10); + const b = m.get(day); out.push({ day, count: b ? b.count : 0, uniques: b ? b.uniques : 0 }); + } + return out; +} + +// ------------------------------------------------------------------ main +async function main() { + TOKEN = ghToken(); + const now = new Date(); + const prev = latestSnapshot(); + const prevAgeH = prev ? (now - Date.parse(prev.at)) / 3600000 : null; + // Baseline: the previous snapshot if it is plausibly "yesterday's"; else a clock window. + const useBaseline = prev && prevAgeH >= 6 && prevAgeH <= 96; + const cutoff = useBaseline ? new Date(prev.at) : new Date(now - opt.hours * 3600000); + const cutoffMs = cutoff.getTime(); + const cutoffIso = cutoff.toISOString(); + const base = useBaseline ? prev : null; + const prevRepos = new Map(Object.entries(base?.repos || {})); + + const me = (await gh('/user')).data; + console.error(`gh-pulse: ${me.login}, window since ${cutoffIso}${useBaseline ? ' (previous snapshot)' : ' (clock)'}`); + + // 1. every repo the token can see, minus forks + let repos = await ghAll('/user/repos?affiliation=owner,organization_member&per_page=100&sort=pushed', { max: 30 }); + repos = repos.filter((r) => !r.fork); + if (opt.repos.length) repos = repos.filter((r) => opt.repos.includes(r.full_name)); + console.error(`gh-pulse: ${repos.length} repos`); + + // 2. traffic for all of them (this is the bulk of the calls) + const R = new Map(); + await Promise.all(repos.map((r) => limit(async () => { + const [v, c] = await Promise.all([ + gh(`/repos/${r.full_name}/traffic/views`, { allow: [403, 404] }), + gh(`/repos/${r.full_name}/traffic/clones`, { allow: [403, 404] }), + ]); + R.set(r.full_name, { + repo: r, + views: v.data ? v.data.views : null, clones: c.data ? c.data.clones : null, + trafficOk: !!(v.data && c.data), + }); + }))); + + // 3. movement per repo + { + // GitHub pads empty buckets up to today for some repos, so "newest" means + // the newest day on which any repo actually recorded something. + const days = [...R.values()].flatMap((x) => [...(x.views || []), ...(x.clones || [])]).filter((b) => b.count > 0).map((b) => b.timestamp.slice(0, 10)); + if (days.length) NEWEST_DAY = days.reduce((a, b) => (b > a ? b : a)); + } + const firstRunSince = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 10); + for (const [name, x] of R) { + const r = x.repo; const p = prevRepos.get(name); + const m = { + stars: p ? r.stargazers_count - p.stars : 0, forks: p ? r.forks_count - p.forks : 0, + commits: 0, authors: [], prOpened: 0, prMerged: 0, prClosed: 0, issuesOpened: 0, issuesClosed: 0, releases: 0, + views: 0, uniques: 0, clones: 0, cloners: 0, newStargazers: [], newForks: [], mergedPrs: [], openedPrs: [], openedIssues: [], releaseList: [], + }; + const dv = trafficDelta(x.views, p ? p.views : null, firstRunSince); + const dc = trafficDelta(x.clones, p ? p.clones : null, firstRunSince); + m.views = dv.count; m.uniques = dv.uniques; m.clones = dc.count; m.cloners = dc.uniques; + x.m = m; + x.needEvents = Date.parse(r.pushed_at) >= cutoffMs || Date.parse(r.updated_at) >= cutoffMs || + (p && r.open_issues_count !== p.openIssues); + x.needStars = (p ? m.stars > 0 : r.stargazers_count > 0); + x.needForks = (p ? m.forks > 0 : r.forks_count > 0); + } + + // 4. detail for candidates + await Promise.all([...R.values()].map((x) => limit(async () => { + const r = x.repo; const m = x.m; const n = r.full_name; + if (x.needStars) { + // Newest stargazers live on the LAST page. + const first = await gh(`/repos/${n}/stargazers?per_page=100`, { accept: 'application/vnd.github.star+json', allow: [404] }); + let page = first.data || []; + const last = lastPage(first.link); + if (last > 1) page = (await gh(`/repos/${n}/stargazers?per_page=100&page=${last}`, { accept: 'application/vnd.github.star+json' })).data; + const recent = page.filter((s) => Date.parse(s.starred_at) >= cutoffMs); + m.newStargazers = recent.map((s) => s.user.login).reverse(); + if (!prevRepos.get(n)) m.stars = recent.length; // no baseline: what starred_at says + } + if (x.needForks) { + const f = await gh(`/repos/${n}/forks?sort=newest&per_page=30`, { allow: [404] }); + const recent = (f.data || []).filter((k) => Date.parse(k.created_at) >= cutoffMs); + m.newForks = recent.map((k) => k.full_name); + if (!prevRepos.get(n)) m.forks = recent.length; + } + if (x.needEvents) { + const [commits, pulls, issues, releases] = await Promise.all([ + ghAll(`/repos/${n}/commits?since=${cutoffIso}&per_page=100`, { max: 3, allow: [409, 404] }), + gh(`/repos/${n}/pulls?state=all&sort=updated&direction=desc&per_page=60`, { allow: [404] }), + ghAll(`/repos/${n}/issues?state=all&since=${cutoffIso}&sort=updated&per_page=100`, { max: 2, allow: [404] }), + gh(`/repos/${n}/releases?per_page=10`, { allow: [404] }), + ]); + m.commits = commits.length; + m.authors = [...new Set(commits.map((c) => c.author?.login || c.commit?.author?.name).filter(Boolean))]; + for (const pr of pulls.data || []) { + if (Date.parse(pr.created_at) >= cutoffMs) { m.prOpened++; m.openedPrs.push({ n: pr.number, t: pr.title, u: pr.user?.login, url: pr.html_url }); } + if (pr.merged_at && Date.parse(pr.merged_at) >= cutoffMs) { m.prMerged++; m.mergedPrs.push({ n: pr.number, t: pr.title, u: pr.user?.login, url: pr.html_url }); } + else if (pr.closed_at && Date.parse(pr.closed_at) >= cutoffMs) m.prClosed++; + } + for (const is of issues) { + if (is.pull_request) continue; + if (Date.parse(is.created_at) >= cutoffMs) { m.issuesOpened++; m.openedIssues.push({ n: is.number, t: is.title, u: is.user?.login, url: is.html_url }); } + if (is.closed_at && Date.parse(is.closed_at) >= cutoffMs) m.issuesClosed++; + } + for (const rel of releases.data || []) { + if (rel.published_at && Date.parse(rel.published_at) >= cutoffMs) { m.releases++; m.releaseList.push({ tag: rel.tag_name, url: rel.html_url }); } + } + } + m.score = score(m); m.mover = isMover(m); + }))); + + // 5. referrers + popular paths, only where something moved + const movers = [...R.values()].filter((x) => x.m.mover).sort((a, b) => b.m.score - a.m.score); + await Promise.all(movers.map((x) => limit(async () => { + if (!x.trafficOk) return; + const n = x.repo.full_name; + const [ref, paths] = await Promise.all([ + gh(`/repos/${n}/traffic/popular/referrers`, { allow: [403, 404] }), + gh(`/repos/${n}/traffic/popular/paths`, { allow: [403, 404] }), + ]); + x.referrers = ref.data || []; x.paths = paths.data || []; + }))); + + // 6. followers, by name + const followers = (await ghAll('/user/followers?per_page=100', { max: 60 })).map((u) => u.login).sort(); + const prevFollowers = new Set(base?.followers || []); + const followerMoves = base ? { + gained: followers.filter((l) => !prevFollowers.has(l)), + lost: [...prevFollowers].filter((l) => !followers.includes(l)), + } : { gained: [], lost: [] }; + + // 7. portfolio totals + const totalStars = repos.reduce((s, r) => s + r.stargazers_count, 0); + const prevTotalStars = base ? Object.values(base.repos).reduce((s, r) => s + r.stars, 0) : null; + const port = { views: 0, uniques: 0, clones: 0, cloners: 0, viewsByDay: new Map(), clonesByDay: new Map() }; + for (const x of R.values()) { + port.views += x.m.views; port.uniques += x.m.uniques; port.clones += x.m.clones; port.cloners += x.m.cloners; + for (const b of fourteenDays(x.views)) port.viewsByDay.set(b.day, (port.viewsByDay.get(b.day) || 0) + b.count); + for (const b of fourteenDays(x.clones)) port.clonesByDay.set(b.day, (port.clonesByDay.get(b.day) || 0) + b.count); + } + const trafficBlind = [...R.values()].filter((x) => !x.trafficOk).length; + + // 8. charts + const { Resvg } = await loadResvg(); + const png = (svg) => new Resvg(svg, { fitTo: { mode: 'zoom', value: 2 }, font: { loadSystemFonts: true, defaultFontFamily: 'DejaVu Sans' } }).render().asPng(); + const images = []; + const addImage = (id, svg) => { images.push({ id, buf: png(svg) }); return `cid:${id}`; }; + const portDays = [...port.viewsByDay.keys()].sort(); + const portfolioImg = addImage('portfolio', chartPair( + { title: 'Views per day, all repos', color: '#2a78d6', days: portDays.map((d) => ({ day: d, count: port.viewsByDay.get(d) })) }, + { title: 'Clones per day, all repos', color: '#eb6834', days: portDays.map((d) => ({ day: d, count: port.clonesByDay.get(d) })) }, + 640, 170)); + const detailed = movers.slice(0, opt.top); + for (const x of detailed) { + if (!x.trafficOk) continue; + x.img = addImage('r' + x.repo.id, chartPair( + { title: 'Views', color: '#2a78d6', days: fourteenDays(x.views) }, + { title: 'Clones', color: '#eb6834', days: fourteenDays(x.clones) }, 640, 120)); + } + + // 9. render + const ctx = { me, now, cutoff, useBaseline, prevAgeH, newestDay: NEWEST_DAY, trafficLabel: useBaseline ? 'new since last run' : `GitHub days from ${firstRunSince}`, repos, movers, detailed, followers, followerMoves, totalStars, prevTotalStars, port, trafficBlind, portfolioImg, opt, prevSnapshotFile: base?.file }; + const html = renderHtml(ctx); + const text = renderText(ctx); + fs.mkdirSync(OUT, { recursive: true }); + fs.writeFileSync(path.join(OUT, 'latest.html'), html.replace(/cid:([\w-]+)/g, (_, id) => `data:image/png;base64,${images.find((i) => i.id === id).buf.toString('base64')}`)); + fs.writeFileSync(path.join(OUT, 'latest.txt'), text); + fs.writeFileSync(path.join(OUT, 'latest.json'), JSON.stringify(reportJson(ctx), null, 2)); + fs.mkdirSync(path.join(OUT, 'charts'), { recursive: true }); + for (const i of images) fs.writeFileSync(path.join(OUT, 'charts', `${i.id}.png`), i.buf); + + const subject = `GitHub pulse ${now.toISOString().slice(0, 10)}: ${movers.length} repos moved` + + (totalStars - (prevTotalStars ?? totalStars) ? `, ${signed(totalStars - prevTotalStars)} stars` : '') + + `, ${port.views} views, ${port.clones} clones`; + + // 10. send + if (opt.dryRun) { + console.error(`gh-pulse: dry run, wrote ${path.join(OUT, 'latest.html')} (subject: ${subject})`); + } else { + await sendResend({ to: opt.to, subject, html, text, images }); + console.error(`gh-pulse: sent to ${opt.to}: ${subject}`); + } + + // 11. snapshot (the history). A dry run must not move the baseline. + if (!opt.dryRun && !opt.repos.length) { + fs.mkdirSync(SNAPS, { recursive: true }); + const snap = { at: now.toISOString(), user: me.login, followersCount: me.followers, followers, repos: {} }; + for (const [n, x] of R) { + const r = x.repo; + snap.repos[n] = { id: r.id, stars: r.stargazers_count, forks: r.forks_count, openIssues: r.open_issues_count, pushedAt: r.pushed_at, + private: r.private, archived: r.archived, views: x.views, clones: x.clones, referrers: x.referrers || undefined, paths: x.paths || undefined, movement: x.m.mover ? x.m : undefined }; + } + const f = path.join(SNAPS, now.toISOString().slice(0, 13).replace('T', 'T') + '.json.gz'); + fs.writeFileSync(f, zlib.gzipSync(JSON.stringify(snap))); + console.error(`gh-pulse: snapshot ${f}`); + } + console.error(`gh-pulse: ${stats.calls} API calls (${stats.retries} retries), ${rateRemaining} remaining this hour`); +} + +// ------------------------------------------------------------------ charts (SVG -> PNG) +// Single-series bar charts, one hue each, thin marks, direct labels only on +// the peak and the latest day, recessive grid. Two side by side in one image. +function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } +function chartPair(a, b, W = 640, H = 150) { + const half = W / 2; + return `` + + `` + bars(a, 0, 0, half - 12, H) + bars(b, half + 12, 0, half - 12, H) + ''; +} +function bars({ title, color, days }, x0, y0, w, h) { + const padT = 38, padB = 20, padL = 8, padR = 8; + const max = Math.max(1, ...days.map((d) => d.count)); + const n = days.length; const gap = 2; const bw = (w - padL - padR - gap * (n - 1)) / n; + const ph = h - padT - padB; const baseY = y0 + padT + ph; + let s = `${esc(title)}`; + const total = days.reduce((t, d) => t + d.count, 0); + s += `${total} in 14d`; + for (const f of [0.5, 1]) { const gy = baseY - ph * f; s += ``; } + s += ``; + const peak = days.reduce((p, d, i) => (d.count > days[p].count ? i : p), 0); + days.forEach((d, i) => { + const bx = x0 + padL + i * (bw + gap); const bh = d.count ? Math.max(2, (d.count / max) * ph) : 0; + if (bh) s += ``; + if (d.count && (i === peak || i === n - 1)) s += `${d.count}`; + if (i === 0 || i === n - 1 || i === 7) s += `${d.day.slice(5)}`; + }); + return s; +} +function roundTop(x, y, w, h, r) { + r = Math.min(r, h); + return `M${x} ${y + h} V${y + r} Q${x} ${y} ${x + r} ${y} H${x + w - r} Q${x + w} ${y} ${x + w} ${y + r} V${y + h} Z`; +} + +// ------------------------------------------------------------------ html +const signed = (n) => (n > 0 ? `+${n}` : `${n}`); +const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`; +function movementBits(m) { + const b = []; + if (m.stars) b.push(`${signed(m.stars)} star${Math.abs(m.stars) === 1 ? '' : 's'}`); + if (m.forks) b.push(`${signed(m.forks)} fork${Math.abs(m.forks) === 1 ? '' : 's'}`); + if (m.commits) b.push(plural(m.commits, 'commit')); + if (m.prMerged) b.push(`${plural(m.prMerged, 'PR')} merged`); + if (m.prOpened) b.push(`${plural(m.prOpened, 'PR')} opened`); + if (m.prClosed) b.push(`${plural(m.prClosed, 'PR')} closed`); + if (m.issuesOpened) b.push(`${plural(m.issuesOpened, 'issue')} opened`); + if (m.issuesClosed) b.push(`${plural(m.issuesClosed, 'issue')} closed`); + if (m.releases) b.push(plural(m.releases, 'release')); + return b; +} +function trafficBits(m) { + const b = []; + if (m.views) b.push(`${m.views} view${m.views === 1 ? '' : 's'} / ${m.uniques} unique`); + if (m.clones) b.push(`${m.clones} clone${m.clones === 1 ? '' : 's'} / ${m.cloners} unique`); + return b; +} +const fmtWhen = (d) => d.toUTCString().replace(/:\d\d GMT$/, ' UTC'); + +function renderHtml(c) { + const { movers, detailed } = c; + const maxScore = Math.max(1, ...movers.map((x) => x.m.score)); + const windowH = Math.round((c.now - c.cutoff) / 3600000); + const link = (x) => `${esc(x.repo.full_name)}` + + (x.repo.private ? ' private' : ''); + const tile = (label, value, sub) => `` + + `
${label}
` + + `
${value}
` + + (sub ? `
${sub}
` : '') + ''; + const starDelta = c.prevTotalStars === null ? '' : `${signed(c.totalStars - c.prevTotalStars)} in window`; + const followerDelta = c.useBaseline ? `${signed(c.followerMoves.gained.length - c.followerMoves.lost.length)} (${c.followerMoves.gained.length} new, ${c.followerMoves.lost.length} lost)` : 'baseline captured'; + + let h = `
+
+
GitHub pulse
+
${esc(c.me.login)} · ${fmtWhen(c.now)} · window ${windowH}h since ${fmtWhen(c.cutoff)}${c.useBaseline ? '' : ' (no previous snapshot; clock window)'}
+ +${tile('Repos moved', movers.length, `of ${c.repos.length} scanned`)} +${tile('Stars', c.totalStars.toLocaleString('en-US'), starDelta || 'all repos')} +${tile('Views', c.port.views.toLocaleString('en-US'), `${c.port.uniques.toLocaleString('en-US')} unique visitors · ${esc(c.trafficLabel)}`)} +${tile('Clones', c.port.clones.toLocaleString('en-US'), `${c.port.cloners.toLocaleString('en-US')} unique cloners · ${esc(c.trafficLabel)}`)} +
+
Followers: ${c.followers.length.toLocaleString('en-US')} · ${followerDelta}${c.trafficBlind ? ` · traffic unavailable for ${c.trafficBlind} repos (no push access)` : ''}
+14-day views and clones across all repos +`; + + // ranking + h += `
Ranked by movement
`; + if (!movers.length) h += `
Nothing moved in the window.
`; + h += ``; + movers.slice(0, 40).forEach((x, i) => { + const pct = Math.max(2, Math.round((x.m.score / maxScore) * 100)); + const bits = [...movementBits(x.m), ...trafficBits(x.m)].join(' · '); + h += `` + + ``; + }); + h += `
${i + 1}${link(x)}
${esc(bits)}
` + + `
 ${x.m.score.toFixed(0)} pts
`; + if (movers.length > 40) h += `
and ${movers.length - 40} more with smaller movement
`; + + // details + if (detailed.length) h += `
Traffic for the top ${detailed.length}
`; + const listItems = (arr, f) => arr.slice(0, 5).map(f).join(''); + for (const x of detailed) { + const m = x.m; const r = x.repo; + const v14 = fourteenDays(x.views).reduce((t, d) => t + d.count, 0); + const c14 = fourteenDays(x.clones).reduce((t, d) => t + d.count, 0); + h += `
+
${link(x)} ★ ${r.stargazers_count} · ⑂ ${r.forks_count} · ${m.score.toFixed(0)} pts
+
${esc(movementBits(m).join(' · ') || 'traffic only')}
`; + if (x.trafficOk) { + h += ` + + + +
${esc(c.trafficLabel)}14 days to ${esc(c.newestDay)}
Views / unique visitors${m.views} / ${m.uniques}${v14}
Clones / unique cloners${m.clones} / ${m.cloners}${c14}
`; + if (x.img) h += `14-day views and clones for ${esc(r.full_name)}`; + const cols = []; + if (x.referrers?.length) cols.push(`
Referrers (14d)
` + listItems(x.referrers, (q) => `
${esc(q.referrer)} ${q.count} views · ${q.uniques} unique
`)); + if (x.paths?.length) cols.push(`
Popular content (14d)
` + listItems(x.paths, (q) => `
${esc(q.path.replace('/' + r.full_name, '') || '/')} ${q.count} views · ${q.uniques} unique
`)); + if (cols.length) h += `${cols.map((col) => ``).join('')}
${col}
`; + } else { + h += `
Traffic not available (needs push access).
`; + } + const extras = []; + if (m.newStargazers.length) extras.push(`Starred by ${m.newStargazers.slice(0, 12).map((l) => `${esc(l)}`).join(', ')}${m.newStargazers.length > 12 ? ` and ${m.newStargazers.length - 12} more` : ''}`); + if (m.newForks.length) extras.push(`Forked by ${m.newForks.slice(0, 8).map((f) => esc(f.split('/')[0])).join(', ')}`); + if (m.authors.length) extras.push(`Commits by ${m.authors.slice(0, 6).map(esc).join(', ')}`); + if (m.mergedPrs.length) extras.push(`Merged: ${m.mergedPrs.slice(0, 5).map((p) => `#${p.n} ${esc(p.t)}`).join('; ')}`); + if (m.openedPrs.length) extras.push(`Opened PRs: ${m.openedPrs.slice(0, 5).map((p) => `#${p.n} ${esc(p.t)} (${esc(p.u)})`).join('; ')}`); + if (m.openedIssues.length) extras.push(`Opened issues: ${m.openedIssues.slice(0, 5).map((p) => `#${p.n} ${esc(p.t)} (${esc(p.u)})`).join('; ')}`); + if (m.releaseList.length) extras.push(`Released ${m.releaseList.map((p) => `${esc(p.tag)}`).join(', ')}`); + if (extras.length) h += `
${extras.map((e) => `
${e}
`).join('')}
`; + h += `
`; + } + + // followers + if (c.followerMoves.gained.length || c.followerMoves.lost.length) { + h += `
Followers
`; + if (c.followerMoves.gained.length) h += `
New: ${c.followerMoves.gained.slice(0, 30).map((l) => `${esc(l)}`).join(', ')}
`; + if (c.followerMoves.lost.length) h += `
Unfollowed: ${c.followerMoves.lost.slice(0, 30).map(esc).join(', ')}
`; + h += `
`; + } + + h += `
Scoring: star 5 · fork 4 · release 5 · PR merged 3 · PR/issue opened 2 · commit 1 (capped 25) · unique visitor 1 · unique cloner 1 · clone 0.5 · view 0.2. Traffic is GitHub's own /graphs/traffic data, which GitHub publishes one to two days late; it is counted as growth of the 14-day buckets since the previous run, so nothing is missed or double counted. Generated by gh-pulse on ${esc(os.hostname())}.
+
`; + return h; +} + +function renderText(c) { + const out = []; + out.push(`GITHUB PULSE ${c.now.toISOString().slice(0, 10)} (${c.me.login})`); + out.push(`window since ${c.cutoff.toISOString()} · traffic: ${c.trafficLabel}, newest GitHub day ${c.newestDay}`); + out.push(''); + out.push(`${c.movers.length} of ${c.repos.length} repos moved · stars ${c.totalStars}${c.prevTotalStars === null ? '' : ` (${signed(c.totalStars - c.prevTotalStars)})`} · views ${c.port.views}/${c.port.uniques} unique · clones ${c.port.clones}/${c.port.cloners} unique · followers ${c.followers.length} (${signed(c.followerMoves.gained.length - c.followerMoves.lost.length)})`); + out.push(''); + c.movers.forEach((x, i) => { + out.push(`${String(i + 1).padStart(2)}. ${x.repo.full_name} ${x.m.score.toFixed(0)} pts`); + out.push(` ${[...movementBits(x.m), ...trafficBits(x.m)].join(' · ')}`); + }); + return out.join('\n') + '\n'; +} + +function reportJson(c) { + return { + at: c.now.toISOString(), since: c.cutoff.toISOString(), newestTrafficDay: c.newestDay, trafficLabel: c.trafficLabel, user: c.me.login, followers: c.followers.length, + followersGained: c.followerMoves.gained, followersLost: c.followerMoves.lost, + totals: { repos: c.repos.length, stars: c.totalStars, starsDelta: c.prevTotalStars === null ? null : c.totalStars - c.prevTotalStars, ...pick(c.port, ['views', 'uniques', 'clones', 'cloners']) }, + movers: c.movers.map((x) => ({ repo: x.repo.full_name, private: x.repo.private, url: x.repo.html_url, stars: x.repo.stargazers_count, forks: x.repo.forks_count, score: Number(x.m.score.toFixed(2)), + movement: pick(x.m, ['stars', 'forks', 'commits', 'prOpened', 'prMerged', 'prClosed', 'issuesOpened', 'issuesClosed', 'releases', 'views', 'uniques', 'clones', 'cloners', 'newStargazers', 'newForks', 'authors', 'mergedPrs', 'openedPrs', 'openedIssues', 'releaseList']), + views14d: fourteenDays(x.views), clones14d: fourteenDays(x.clones), referrers: x.referrers || [], paths: x.paths || [] })), + }; +} +const pick = (o, ks) => Object.fromEntries(ks.map((k) => [k, o[k]])); + +// ------------------------------------------------------------------ mail +async function sendResend({ to, subject, html, text, images }) { + const key = process.env.RESEND_API_KEY; + if (!key) throw new Error('RESEND_API_KEY is not set (and not in ~/.config/logicsrc/shell.env)'); + const from = process.env.GH_PULSE_FROM || 'GitHub Pulse '; + const body = { + from, to: to.split(',').map((s) => s.trim()), subject, html, text, + attachments: images.map((i) => ({ filename: `${i.id}.png`, content: i.buf.toString('base64'), content_type: 'image/png', content_id: i.id })), + }; + const r = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const j = await r.json().catch(() => ({})); + if (!r.ok || !j.id) throw new Error(`Resend ${r.status}: ${JSON.stringify(j).slice(0, 300)}`); + return j.id; +} + +async function failMail(err) { + if (opt.dryRun || !process.env.RESEND_API_KEY) return; + try { + await fetch('https://api.resend.com/emails', { method: 'POST', headers: { authorization: `Bearer ${process.env.RESEND_API_KEY}`, 'content-type': 'application/json' }, + body: JSON.stringify({ from: process.env.GH_PULSE_FROM || 'GitHub Pulse ', to: [opt.to], subject: 'GitHub pulse FAILED', + text: `The daily GitHub pulse could not be produced on ${os.hostname()}.\n\n${err.stack || err}\n` }) }); + } catch {} +} + +main().catch(async (err) => { + console.error(`gh-pulse: ${err.stack || err}`); + await failMail(err); + process.exit(1); +}); diff --git a/lib/gh-pulse/.gitignore b/lib/gh-pulse/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/lib/gh-pulse/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/lib/gh-pulse/package-lock.json b/lib/gh-pulse/package-lock.json new file mode 100644 index 0000000..2b63afb --- /dev/null +++ b/lib/gh-pulse/package-lock.json @@ -0,0 +1,240 @@ +{ + "name": "gh-pulse-deps", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gh-pulse-deps", + "dependencies": { + "@resvg/resvg-js": "^2.6.2" + } + }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/lib/gh-pulse/package.json b/lib/gh-pulse/package.json new file mode 100644 index 0000000..e277edb --- /dev/null +++ b/lib/gh-pulse/package.json @@ -0,0 +1,8 @@ +{ + "name": "gh-pulse-deps", + "private": true, + "description": "Runtime dependencies for bin/gh-pulse. Installed on first run; never published.", + "dependencies": { + "@resvg/resvg-js": "^2.6.2" + } +}