From 0d5cff33befe680c173204ff8b077fe90e1b5fc2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 04:35:33 +0000 Subject: [PATCH] Retire gh-pulse here: it lives in cli-tools now PR #10 landed gh-pulse in this repo and profullstack/cli-tools#69 landed the same tool, ported to TypeScript, with the terminal view, open, text and json on top of the email. Two implementations of one name on PATH is the drift cli-tools list marks with `!`, so this copy goes and the README points at the other. No release was cut between the two, so nothing installed on any box changes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011gfb1vcQH5rf82HfbJvf1Z --- README.md | 42 +-- 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, 6 insertions(+), 903 deletions(-) delete mode 100755 bin/gh-pulse delete mode 100644 lib/gh-pulse/.gitignore delete mode 100644 lib/gh-pulse/package-lock.json delete mode 100644 lib/gh-pulse/package.json 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" - } -}