chapa-cli 0.1.2 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +27 -16
  2. package/dist/index.js +170 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,44 +4,55 @@ Merge GitHub EMU (Enterprise Managed User) contributions into your [Chapa](https
4
4
 
5
5
  If your employer uses GitHub EMU accounts, your work contributions are invisible on your personal profile. This CLI fetches your EMU stats and uploads them to Chapa so your badge reflects your full impact.
6
6
 
7
- ## Usage
7
+ ## Quick start
8
8
 
9
9
  ```bash
10
- npx chapa-cli merge \
11
- --handle your-personal-handle \
12
- --emu-handle your-emu-handle
10
+ # 1. Authenticate with Chapa (opens browser)
11
+ npx chapa-cli login
12
+
13
+ # 2. Merge your EMU stats
14
+ npx chapa-cli merge --emu-handle your-emu-handle
13
15
  ```
14
16
 
15
- ## Options
17
+ That's it! Your personal handle and auth token are auto-detected from the login step.
18
+
19
+ ## Commands
20
+
21
+ | Command | Description |
22
+ |---------|-------------|
23
+ | `chapa login` | Authenticate via browser (like `npm login`) |
24
+ | `chapa logout` | Clear stored credentials |
25
+ | `chapa merge` | Fetch EMU stats and upload to Chapa |
26
+
27
+ ## Options (for merge)
16
28
 
17
29
  | Flag | Description | Required |
18
30
  |------|-------------|----------|
19
- | `--handle <handle>` | Your personal GitHub handle | Yes |
20
31
  | `--emu-handle <handle>` | Your EMU GitHub handle | Yes |
21
32
  | `--emu-token <token>` | EMU GitHub token | No* |
22
- | `--token <token>` | Personal GitHub token | No* |
33
+ | `--handle <handle>` | Override personal handle | No |
34
+ | `--token <token>` | Override auth token | No |
23
35
  | `--server <url>` | Chapa server URL | No |
24
36
  | `--version`, `-v` | Show version number | |
25
37
  | `--help`, `-h` | Show help message | |
26
38
 
27
- *Tokens are resolved in order: flag > environment variable > `gh auth token`.
39
+ *EMU token is resolved from: flag > `GITHUB_EMU_TOKEN` env var.
28
40
 
29
- ## Token setup
41
+ ## EMU token setup
30
42
 
31
- Set tokens via environment variables to avoid passing them on every run:
43
+ You need one token from your EMU (work) GitHub account with `read:user` scope:
32
44
 
33
45
  ```bash
34
- export GITHUB_TOKEN=ghp_your_personal_token
35
46
  export GITHUB_EMU_TOKEN=ghp_your_emu_token
36
47
  ```
37
48
 
38
- Tokens need the `read:user` scope at minimum.
39
-
40
49
  ## How it works
41
50
 
42
- 1. Fetches your last 90 days of activity from the EMU account via GitHub GraphQL API
43
- 2. Uploads the stats (commits, PRs merged, reviews) to the Chapa server
44
- 3. Your Chapa badge recalculates on next refresh, combining personal + EMU contributions
51
+ 1. `chapa login` opens your browser for OAuth authentication with Chapa
52
+ 2. After approval, a signed CLI token is saved to `~/.chapa/credentials.json`
53
+ 3. `chapa merge` fetches your last 12 months of EMU activity via GitHub GraphQL API
54
+ 4. Stats (commits, PRs merged, reviews) are uploaded to the Chapa server
55
+ 5. Your badge recalculates on next refresh, combining personal + EMU contributions
45
56
 
46
57
  ## License
47
58
 
package/dist/index.js CHANGED
@@ -3,9 +3,10 @@
3
3
  // src/cli.ts
4
4
  import { parseArgs as nodeParseArgs } from "util";
5
5
  var DEFAULT_SERVER = "https://chapa.thecreativetoken.com";
6
+ var VALID_COMMANDS = ["merge", "login", "logout"];
6
7
  function parseArgs(argv) {
7
- const positional = argv.find((a) => !a.startsWith("--"));
8
- const command = positional === "merge" ? "merge" : null;
8
+ const positional = argv.find((a) => !a.startsWith("--") && !a.startsWith("-"));
9
+ const command = VALID_COMMANDS.includes(positional) ? positional : null;
9
10
  const flagArgs = argv.filter((a) => a !== positional || a.startsWith("--"));
10
11
  const { values } = nodeParseArgs({
11
12
  args: flagArgs,
@@ -90,6 +91,9 @@ query($login: String!, $since: DateTime!, $until: DateTime!, $historySince: GitT
90
91
  }
91
92
  }
92
93
  }
94
+ ownedRepos: repositories(ownerAffiliations: OWNER, first: 100, orderBy: {field: STARGAZERS, direction: DESC}) {
95
+ nodes { stargazerCount forkCount watchers { totalCount } }
96
+ }
93
97
  }
94
98
  }
95
99
  `;
@@ -100,8 +104,12 @@ function computePrWeight(pr) {
100
104
  return Math.min(w, 3);
101
105
  }
102
106
 
107
+ // ../shared/src/constants.ts
108
+ var SCORING_WINDOW_DAYS = 365;
109
+ var PR_WEIGHT_AGG_CAP = 120;
110
+
103
111
  // ../shared/src/stats-aggregation.ts
104
- function buildStats90dFromRaw(raw) {
112
+ function buildStatsFromRaw(raw) {
105
113
  const heatmapData = [];
106
114
  for (const week of raw.contributionCalendar.weeks) {
107
115
  for (const day of week.contributionDays) {
@@ -114,7 +122,7 @@ function buildStats90dFromRaw(raw) {
114
122
  const prsMergedCount = mergedPRs.length;
115
123
  const prsMergedWeight = Math.min(
116
124
  mergedPRs.reduce((sum, pr) => sum + computePrWeight(pr), 0),
117
- 40
125
+ PR_WEIGHT_AGG_CAP
118
126
  );
119
127
  const linesAdded = mergedPRs.reduce((sum, pr) => sum + pr.additions, 0);
120
128
  const linesDeleted = mergedPRs.reduce((sum, pr) => sum + pr.deletions, 0);
@@ -129,6 +137,18 @@ function buildStats90dFromRaw(raw) {
129
137
  const topRepoShare = totalRepoCommits > 0 ? Math.max(...repoCommits.map((r) => r.commits)) / totalRepoCommits : 0;
130
138
  const maxDailyCount = Math.max(...heatmapData.map((d) => d.count), 0);
131
139
  const maxCommitsIn10Min = maxDailyCount >= 30 ? maxDailyCount : 0;
140
+ const totalStars = raw.ownedRepoStars.nodes.reduce(
141
+ (sum, r) => sum + r.stargazerCount,
142
+ 0
143
+ );
144
+ const totalForks = raw.ownedRepoStars.nodes.reduce(
145
+ (sum, r) => sum + r.forkCount,
146
+ 0
147
+ );
148
+ const totalWatchers = raw.ownedRepoStars.nodes.reduce(
149
+ (sum, r) => sum + r.watchers.totalCount,
150
+ 0
151
+ );
132
152
  return {
133
153
  handle: raw.login,
134
154
  displayName: raw.name ?? void 0,
@@ -144,16 +164,19 @@ function buildStats90dFromRaw(raw) {
144
164
  reposContributed,
145
165
  topRepoShare,
146
166
  maxCommitsIn10Min,
167
+ totalStars,
168
+ totalForks,
169
+ totalWatchers,
147
170
  heatmapData,
148
171
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
149
172
  };
150
173
  }
151
174
 
152
175
  // src/fetch-emu.ts
153
- async function fetchEmuStats(login, emuToken) {
176
+ async function fetchEmuStats(login2, emuToken) {
154
177
  const now = /* @__PURE__ */ new Date();
155
178
  const since = new Date(now);
156
- since.setDate(since.getDate() - 90);
179
+ since.setDate(since.getDate() - SCORING_WINDOW_DAYS);
157
180
  try {
158
181
  const res = await fetch("https://api.github.com/graphql", {
159
182
  method: "POST",
@@ -165,7 +188,7 @@ async function fetchEmuStats(login, emuToken) {
165
188
  body: JSON.stringify({
166
189
  query: CONTRIBUTION_QUERY,
167
190
  variables: {
168
- login,
191
+ login: login2,
169
192
  since: since.toISOString(),
170
193
  until: now.toISOString(),
171
194
  historySince: since.toISOString(),
@@ -179,6 +202,9 @@ async function fetchEmuStats(login, emuToken) {
179
202
  return null;
180
203
  }
181
204
  const json = await res.json();
205
+ if (json.errors) {
206
+ console.error(`[cli] GraphQL errors for ${login2}:`, json.errors);
207
+ }
182
208
  if (!json.data?.user) return null;
183
209
  const user = json.data.user;
184
210
  const cc = user.contributionsCollection;
@@ -201,9 +227,12 @@ async function fetchEmuStats(login, emuToken) {
201
227
  repositories: {
202
228
  totalCount: user.repositories.totalCount,
203
229
  nodes: user.repositories.nodes
230
+ },
231
+ ownedRepoStars: {
232
+ nodes: (user.ownedRepos?.nodes ?? []).filter((n) => n != null).map((n) => ({ stargazerCount: n.stargazerCount, forkCount: n.forkCount, watchers: { totalCount: n.watchers.totalCount } }))
204
233
  }
205
234
  };
206
- return buildStats90dFromRaw(raw);
235
+ return buildStatsFromRaw(raw);
207
236
  } catch (err) {
208
237
  console.error(`[cli] fetch error:`, err);
209
238
  return null;
@@ -243,20 +272,117 @@ async function uploadSupplementalStats(opts) {
243
272
  }
244
273
  }
245
274
 
275
+ // src/config.ts
276
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
277
+ import { join } from "path";
278
+ import { homedir } from "os";
279
+ function configDir() {
280
+ return join(homedir(), ".chapa");
281
+ }
282
+ function configPath() {
283
+ return join(configDir(), "credentials.json");
284
+ }
285
+ function loadConfig() {
286
+ const path = configPath();
287
+ if (!existsSync(path)) return null;
288
+ try {
289
+ const raw = readFileSync(path, "utf8");
290
+ const data = JSON.parse(raw);
291
+ if (data.token && data.handle && data.server) {
292
+ return data;
293
+ }
294
+ return null;
295
+ } catch {
296
+ return null;
297
+ }
298
+ }
299
+ function saveConfig(config) {
300
+ const dir = configDir();
301
+ if (!existsSync(dir)) {
302
+ mkdirSync(dir, { mode: 448, recursive: true });
303
+ }
304
+ writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n", {
305
+ mode: 384
306
+ });
307
+ }
308
+ function deleteConfig() {
309
+ const path = configPath();
310
+ if (!existsSync(path)) return false;
311
+ unlinkSync(path);
312
+ return true;
313
+ }
314
+
315
+ // src/login.ts
316
+ import { randomUUID } from "crypto";
317
+ import { exec } from "child_process";
318
+ var POLL_INTERVAL_MS = 2e3;
319
+ var MAX_POLL_ATTEMPTS = 150;
320
+ function openBrowser(url) {
321
+ const cmd = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
322
+ exec(cmd, () => {
323
+ });
324
+ }
325
+ function sleep(ms) {
326
+ return new Promise((resolve) => setTimeout(resolve, ms));
327
+ }
328
+ async function login(serverUrl) {
329
+ const baseUrl = serverUrl.replace(/\/+$/, "");
330
+ const sessionId = randomUUID();
331
+ const authorizeUrl = `${baseUrl}/cli/authorize?session=${sessionId}`;
332
+ console.log("Opening browser for authentication...");
333
+ console.log(`If your browser didn't open, visit:
334
+ ${authorizeUrl}
335
+ `);
336
+ openBrowser(authorizeUrl);
337
+ console.log("Waiting for approval...");
338
+ for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
339
+ await sleep(POLL_INTERVAL_MS);
340
+ let data = null;
341
+ try {
342
+ const res = await fetch(
343
+ `${baseUrl}/api/cli/auth/poll?session=${sessionId}`
344
+ );
345
+ if (!res.ok) continue;
346
+ data = await res.json();
347
+ } catch {
348
+ continue;
349
+ }
350
+ if (data?.status === "approved" && data.token && data.handle) {
351
+ saveConfig({
352
+ token: data.token,
353
+ handle: data.handle,
354
+ server: baseUrl
355
+ });
356
+ console.log(`
357
+ Logged in as ${data.handle}!`);
358
+ console.log("Credentials saved to ~/.chapa/credentials.json");
359
+ return;
360
+ }
361
+ if (data?.status === "expired") {
362
+ console.error("\nSession expired. Please try again.");
363
+ process.exit(1);
364
+ }
365
+ }
366
+ console.error("\nTimed out waiting for approval. Please try again.");
367
+ process.exit(1);
368
+ }
369
+
246
370
  // src/index.ts
247
- var VERSION = true ? "0.1.2" : "0.0.0-dev";
371
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
248
372
  var HELP_TEXT = `chapa-cli v${VERSION}
249
373
 
250
374
  Merge GitHub EMU (Enterprise Managed User) contributions into your Chapa badge.
251
375
 
252
- Usage:
253
- chapa merge --handle <personal> --emu-handle <emu> [options]
376
+ Commands:
377
+ chapa login Authenticate with Chapa (opens browser)
378
+ chapa logout Clear stored credentials
379
+ chapa merge --emu-handle <emu> Merge EMU stats into your badge
254
380
 
255
381
  Options:
256
- --handle <handle> Your personal GitHub handle (required)
257
- --emu-handle <handle> Your EMU GitHub handle (required)
382
+ --emu-handle <handle> Your EMU GitHub handle (required for merge)
258
383
  --emu-token <token> EMU GitHub token (or set GITHUB_EMU_TOKEN)
259
- --token <token> Personal GitHub token (or set GITHUB_TOKEN)
384
+ --handle <handle> Override personal handle (auto-detected from login)
385
+ --token <token> Override auth token (auto-detected from login)
260
386
  --server <url> Chapa server URL (default: https://chapa.thecreativetoken.com)
261
387
  --version, -v Show version number
262
388
  --help, -h Show this help message
@@ -271,25 +397,43 @@ async function main() {
271
397
  console.log(HELP_TEXT);
272
398
  process.exit(0);
273
399
  }
400
+ if (args.command === "login") {
401
+ await login(args.server);
402
+ return;
403
+ }
404
+ if (args.command === "logout") {
405
+ const removed = deleteConfig();
406
+ if (removed) {
407
+ console.log("Logged out. Credentials removed from ~/.chapa/credentials.json");
408
+ } else {
409
+ console.log("Not logged in (no credentials found).");
410
+ }
411
+ return;
412
+ }
274
413
  if (args.command !== "merge") {
275
- console.error("Usage: chapa merge --handle <personal> --emu-handle <emu> [--emu-token <token>] [--token <token>] [--server <url>]");
414
+ console.error("Usage: chapa <login | logout | merge> [options]");
276
415
  console.error("\nRun 'chapa --help' for more information.");
277
416
  process.exit(1);
278
417
  }
279
- const handle = args.handle;
418
+ const config = loadConfig();
419
+ const handle = args.handle ?? config?.handle;
280
420
  const emuHandle = args.emuHandle;
281
- if (!handle || !emuHandle) {
282
- console.error("Error: --handle and --emu-handle are required.");
421
+ if (!emuHandle) {
422
+ console.error("Error: --emu-handle is required.");
423
+ process.exit(1);
424
+ }
425
+ if (!handle) {
426
+ console.error("Error: No personal handle found. Run 'chapa login' first, or pass --handle.");
283
427
  process.exit(1);
284
428
  }
285
429
  const emuToken = resolveToken(args.emuToken, "GITHUB_EMU_TOKEN");
286
430
  if (!emuToken) {
287
- console.error("Error: EMU token required. Use --emu-token, set GITHUB_EMU_TOKEN, or ensure `gh auth token` works.");
431
+ console.error("Error: EMU token required. Use --emu-token or set GITHUB_EMU_TOKEN.");
288
432
  process.exit(1);
289
433
  }
290
- const personalToken = resolveToken(args.token, "GITHUB_TOKEN");
291
- if (!personalToken) {
292
- console.error("Error: Personal token required. Use --token, set GITHUB_TOKEN, or ensure `gh auth token` works.");
434
+ const authToken = args.token ?? config?.token;
435
+ if (!authToken) {
436
+ console.error("Error: Not authenticated. Run 'chapa login' first, or pass --token.");
293
437
  process.exit(1);
294
438
  }
295
439
  console.log(`Fetching stats for EMU account: ${emuHandle}...`);
@@ -299,13 +443,14 @@ async function main() {
299
443
  process.exit(1);
300
444
  }
301
445
  console.log(`Found: ${emuStats.commitsTotal} commits, ${emuStats.prsMergedCount} PRs merged, ${emuStats.reviewsSubmittedCount} reviews`);
302
- console.log(`Uploading supplemental stats to ${args.server}...`);
446
+ const serverUrl = args.server !== "https://chapa.thecreativetoken.com" ? args.server : config?.server ?? args.server;
447
+ console.log(`Uploading supplemental stats to ${serverUrl}...`);
303
448
  const result = await uploadSupplementalStats({
304
449
  targetHandle: handle,
305
450
  sourceHandle: emuHandle,
306
451
  stats: emuStats,
307
- token: personalToken,
308
- serverUrl: args.server
452
+ token: authToken,
453
+ serverUrl
309
454
  });
310
455
  if (!result.success) {
311
456
  console.error(`Error: ${result.error}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chapa-cli",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Merge GitHub EMU contributions into your Chapa developer impact badge",
5
5
  "type": "module",
6
6
  "bin": {