diff --git a/README.md b/README.md index 583db25..1b19e4f 100644 --- a/README.md +++ b/README.md @@ -130,41 +130,12 @@ 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 -``` +Moved to [profullstack/cli-tools](https://github.com/profullstack/cli-tools#gh-pulse) +on 2026-09-13, the same day it landed here, so that the email, the `gh-pulse +show` terminal view, `open`, `text` and `json` are one implementation with one +name on `PATH`. The snapshots under `~/.local/share/gh-pulse` are the same files +either way. `curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh` +installs it. ### `gh-prs-merge` @@ -353,7 +324,6 @@ 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 deleted file mode 100755 index d606009..0000000 --- a/bin/gh-pulse +++ /dev/null @@ -1,618 +0,0 @@ -#!/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 deleted file mode 100644 index c2658d7..0000000 --- a/lib/gh-pulse/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/lib/gh-pulse/package-lock.json b/lib/gh-pulse/package-lock.json deleted file mode 100644 index 2b63afb..0000000 --- a/lib/gh-pulse/package-lock.json +++ /dev/null @@ -1,240 +0,0 @@ -{ - "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 deleted file mode 100644 index e277edb..0000000 --- a/lib/gh-pulse/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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" - } -}