gh-profile-peek 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { program } from 'commander';
6
+ import { analyzeProfile } from '../src/index.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const { version } = JSON.parse(
10
+ readFileSync(join(__dirname, '../package.json'), 'utf8')
11
+ );
12
+
13
+ program
14
+ .name('gh-peek')
15
+ .description('Analyze any GitHub profile from your terminal')
16
+ .version(version)
17
+ .argument('<username>', 'GitHub username to analyze')
18
+ .option('-t, --token <token>', 'GitHub personal access token (or set GITHUB_TOKEN)')
19
+ .option('--top <n>', 'Number of top repos to display', '5')
20
+ .addHelpText(
21
+ 'after',
22
+ `
23
+ Examples:
24
+ $ gh-peek torvalds
25
+ $ gh-peek sindresorhus --top 10
26
+ $ GITHUB_TOKEN=ghp_xxx gh-peek octocat
27
+ $ gh-peek octocat --token ghp_xxx`
28
+ )
29
+ .action(async (username, options) => {
30
+ await analyzeProfile(username, options);
31
+ });
32
+
33
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "gh-profile-peek",
3
+ "version": "1.0.0",
4
+ "description": "Analyze any GitHub profile from your terminal — languages, top repos, activity",
5
+ "type": "module",
6
+ "bin": {
7
+ "gh-peek": "./bin/cli.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "keywords": [
13
+ "github",
14
+ "cli",
15
+ "profile",
16
+ "analyzer",
17
+ "developer-tools",
18
+ "terminal"
19
+ ],
20
+ "license": "MIT",
21
+ "files": [
22
+ "bin/",
23
+ "src/"
24
+ ],
25
+ "dependencies": {
26
+ "chalk": "^5.3.0",
27
+ "commander": "^12.1.0",
28
+ "ora": "^8.1.0"
29
+ }
30
+ }
package/src/analyze.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Merges per-repo language byte counts into a ranked percentage list.
3
+ * @param {object[]} repoLanguageMaps Array of { Language: bytes } objects
4
+ * @returns {{ lang: string, pct: number }[]} Top 8, sorted descending
5
+ */
6
+ export function analyzeLanguages(repoLanguageMaps) {
7
+ const totals = {};
8
+
9
+ for (const map of repoLanguageMaps) {
10
+ for (const [lang, bytes] of Object.entries(map)) {
11
+ totals[lang] = (totals[lang] || 0) + bytes;
12
+ }
13
+ }
14
+
15
+ const total = Object.values(totals).reduce((a, b) => a + b, 0);
16
+ if (total === 0) return [];
17
+
18
+ return Object.entries(totals)
19
+ .map(([lang, bytes]) => ({ lang, pct: (bytes / total) * 100 }))
20
+ .sort((a, b) => b.pct - a.pct)
21
+ .slice(0, 8);
22
+ }
23
+
24
+ /**
25
+ * Picks the top N non-fork repos by star count.
26
+ */
27
+ export function topRepos(repos, n = 5) {
28
+ return [...repos]
29
+ .sort((a, b) => b.stargazers_count - a.stargazers_count)
30
+ .slice(0, n);
31
+ }
32
+
33
+ /**
34
+ * Counts public event types from the last 90 days.
35
+ * @returns {{ type: string, count: number }[]}
36
+ */
37
+ export function analyzeActivity(events) {
38
+ const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
39
+ const counts = {};
40
+
41
+ for (const event of events) {
42
+ if (new Date(event.created_at).getTime() < cutoff) continue;
43
+ // Strip "Event" suffix: "PushEvent" → "Push"
44
+ const type = event.type.replace(/Event$/, '');
45
+ counts[type] = (counts[type] || 0) + 1;
46
+ }
47
+
48
+ return Object.entries(counts)
49
+ .map(([type, count]) => ({ type, count }))
50
+ .sort((a, b) => b.count - a.count)
51
+ .slice(0, 6);
52
+ }
53
+
54
+ export function fmt(n) {
55
+ return Number(n).toLocaleString();
56
+ }
package/src/api.js ADDED
@@ -0,0 +1,37 @@
1
+ const BASE = 'https://api.github.com';
2
+
3
+ async function get(path, token) {
4
+ const headers = { Accept: 'application/vnd.github.v3+json' };
5
+ if (token) headers['Authorization'] = `Bearer ${token}`;
6
+
7
+ const res = await fetch(`${BASE}${path}`, { headers });
8
+
9
+ if (res.status === 404) throw new Error('User not found');
10
+ if (res.status === 403) {
11
+ throw new Error('Rate limit hit — pass --token or set GITHUB_TOKEN to get 5,000 req/hr');
12
+ }
13
+ if (res.status === 401) throw new Error('Bad token — check your GITHUB_TOKEN');
14
+ if (!res.ok) throw new Error(`GitHub API error ${res.status}`);
15
+
16
+ return res.json();
17
+ }
18
+
19
+ export async function fetchUser(username, token) {
20
+ return get(`/users/${username}`, token);
21
+ }
22
+
23
+ export async function fetchRepos(username, token) {
24
+ return get(`/users/${username}/repos?sort=pushed&per_page=100`, token);
25
+ }
26
+
27
+ export async function fetchLanguages(owner, repo, token) {
28
+ try {
29
+ return await get(`/repos/${owner}/${repo}/languages`, token);
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ export async function fetchEvents(username, token) {
36
+ return get(`/users/${username}/events/public?per_page=100`, token);
37
+ }
package/src/display.js ADDED
@@ -0,0 +1,110 @@
1
+ import chalk from 'chalk';
2
+ import { fmt } from './analyze.js';
3
+
4
+ const COL = 72; // max output width
5
+
6
+ // ─── Primitives ──────────────────────────────────────────────────────────────
7
+
8
+ function rule(label) {
9
+ const pad = COL - label.length - 2;
10
+ console.log('\n' + chalk.cyan(label) + ' ' + chalk.dim('─'.repeat(pad)));
11
+ }
12
+
13
+ function kv(label, value, width = 18) {
14
+ const pad = ' '.repeat(Math.max(0, width - label.length));
15
+ console.log(' ' + chalk.dim(label + pad) + chalk.yellow(value));
16
+ }
17
+
18
+ /**
19
+ * Renders a horizontal bar for a percentage value.
20
+ *
21
+ * TODO: Implement this function.
22
+ *
23
+ * @param {number} pct The item's percentage (0–100)
24
+ * @param {number} maxPct The largest percentage in the dataset (used to scale)
25
+ * @param {number} width Total character width of the bar (filled + empty)
26
+ * @returns {string} A bar string like "████████░░░░░░░░"
27
+ *
28
+ * Hints to consider:
29
+ * - How many '█' chars should appear? Scale pct relative to maxPct.
30
+ * - Fill the rest with '░' so every bar is the same total width.
31
+ * - Math.round() prevents fractional char counts.
32
+ * - What should you return if pct is 0?
33
+ */
34
+ function renderBar(pct, maxPct, width) {
35
+ if (maxPct === 0) return '░'.repeat(width);
36
+ const filled = Math.round((pct / maxPct) * width);
37
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
38
+ }
39
+
40
+ // ─── Sections ────────────────────────────────────────────────────────────────
41
+
42
+ export function renderProfile(user) {
43
+ const joined = new Date(user.created_at).getFullYear();
44
+ const name = user.name ? `${user.login} ${chalk.bold(user.name)}` : user.login;
45
+
46
+ console.log('\n' + chalk.bold.white(' ' + name));
47
+
48
+ if (user.bio) console.log(' ' + chalk.dim(user.bio));
49
+
50
+ const loc = user.location ? `📍 ${user.location} ` : '';
51
+ const blog = user.blog ? `🔗 ${user.blog} ` : '';
52
+ if (loc || blog) console.log(' ' + chalk.dim(loc + blog));
53
+
54
+ console.log(
55
+ '\n ' +
56
+ [
57
+ chalk.white(`${fmt(user.public_repos)} repos`),
58
+ chalk.white(`${fmt(user.followers)} followers`),
59
+ chalk.white(`${fmt(user.following)} following`),
60
+ chalk.dim(`since ${joined}`),
61
+ ].join(chalk.dim(' · '))
62
+ );
63
+ }
64
+
65
+ export function renderLanguages(languages) {
66
+ if (languages.length === 0) return;
67
+ rule('Languages');
68
+
69
+ const maxPct = languages[0].pct; // already sorted descending
70
+ const BAR_WIDTH = 24;
71
+ const labelWidth = Math.max(...languages.map((l) => l.lang.length)) + 2;
72
+
73
+ for (const { lang, pct } of languages) {
74
+ const label = lang.padEnd(labelWidth);
75
+ const bar = renderBar(pct, maxPct, BAR_WIDTH);
76
+ const pctStr = pct.toFixed(1).padStart(5) + '%';
77
+ console.log(` ${chalk.white(label)} ${chalk.green(bar)} ${chalk.dim(pctStr)}`);
78
+ }
79
+ }
80
+
81
+ export function renderRepos(repos) {
82
+ if (repos.length === 0) return;
83
+ rule('Top Repositories');
84
+
85
+ const nameWidth = Math.max(...repos.map((r) => r.name.length)) + 2;
86
+
87
+ for (const repo of repos) {
88
+ const name = repo.name.padEnd(nameWidth);
89
+ const stars = chalk.yellow(`★ ${fmt(repo.stargazers_count).padStart(5)}`);
90
+ const forks = chalk.dim(`⑂ ${fmt(repo.forks_count).padStart(5)}`);
91
+ const lang = repo.language ? chalk.cyan(repo.language) : chalk.dim('—');
92
+ const forked = repo.fork ? chalk.dim(' fork') : '';
93
+ console.log(` ${chalk.white(name)} ${stars} ${forks} ${lang}${forked}`);
94
+ }
95
+ }
96
+
97
+ export function renderActivity(activity) {
98
+ if (activity.length === 0) return;
99
+ rule('Activity (last 90 days)');
100
+
101
+ const maxCount = activity[0].count;
102
+ const BAR_WIDTH = 28;
103
+ const labelWidth = Math.max(...activity.map((a) => a.type.length)) + 2;
104
+
105
+ for (const { type, count } of activity) {
106
+ const label = type.padEnd(labelWidth);
107
+ const bar = renderBar(count, maxCount, BAR_WIDTH);
108
+ console.log(` ${chalk.white(label)} ${chalk.blue(bar)} ${chalk.dim(count)}`);
109
+ }
110
+ }
package/src/index.js ADDED
@@ -0,0 +1,47 @@
1
+ import ora from 'ora';
2
+ import chalk from 'chalk';
3
+ import { fetchUser, fetchRepos, fetchLanguages, fetchEvents } from './api.js';
4
+ import { analyzeLanguages, topRepos, analyzeActivity } from './analyze.js';
5
+ import { renderProfile, renderLanguages, renderRepos, renderActivity } from './display.js';
6
+
7
+ export async function analyzeProfile(username, { token, top = 5 }) {
8
+ const resolvedToken = token || process.env.GITHUB_TOKEN;
9
+ const spinner = ora(`Fetching ${chalk.cyan(username)}`).start();
10
+
11
+ try {
12
+ // 1. User profile
13
+ spinner.text = `Fetching profile for ${chalk.cyan(username)}`;
14
+ const user = await fetchUser(username, resolvedToken);
15
+
16
+ // 2. Repos + languages (in parallel, capped at 30 repos to stay within rate limits)
17
+ spinner.text = 'Fetching repositories…';
18
+ const repos = await fetchRepos(username, resolvedToken);
19
+ const sample = repos.slice(0, 30);
20
+
21
+ spinner.text = 'Analyzing languages…';
22
+ const langMaps = await Promise.all(
23
+ sample.map((r) => fetchLanguages(r.owner.login, r.name, resolvedToken))
24
+ );
25
+
26
+ // 3. Recent public events
27
+ spinner.text = 'Fetching recent activity…';
28
+ let events = [];
29
+ try {
30
+ events = await fetchEvents(username, resolvedToken);
31
+ } catch {
32
+ // Events endpoint can be empty for private accounts — not fatal
33
+ }
34
+
35
+ spinner.succeed(chalk.green(`${username} — done`));
36
+
37
+ // ── Render ──────────────────────────────────────────────────────────────
38
+ renderProfile(user);
39
+ renderLanguages(analyzeLanguages(langMaps));
40
+ renderRepos(topRepos(repos, Number(top)));
41
+ renderActivity(analyzeActivity(events));
42
+ console.log('');
43
+ } catch (err) {
44
+ spinner.fail(chalk.red(err.message));
45
+ process.exit(1);
46
+ }
47
+ }