@yaroslavhaidash/gitstats-cli 0.2.0 → 0.3.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 +2 -2
  2. package/dist/cli.js +110 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Counts commits and lines in the git repos on your computer and sends **only the numbers** (per repo, per week) to your [gitstats](https://gitstats-three-zeta.vercel.app) profile. No file contents, no diffs, no GitHub tokens, no permissions on GitHub.
4
4
 
5
5
  ```bash
6
- npx --yes github:yaroslavhaidash/gitstats-cli link
6
+ npx @yaroslavhaidash/gitstats-cli@latest link
7
7
  ```
8
8
 
9
9
  That pairs this computer (you confirm in the browser), scans your home folder for repos, uploads the last year, and installs a daily background sync (launchd on macOS, Task Scheduler on Windows, systemd user timer on Linux). Nothing else to remember.
@@ -14,4 +14,4 @@ What leaves the machine, per repo: an HMAC-SHA256 of the normalised remote URL k
14
14
 
15
15
  You are trusting the operator: `npx github:…` runs the committed `dist/` from this repo. Read `src/cli.ts` (~400 lines) or watch the payload with a proxy.
16
16
 
17
- Commands: `sync` · `status` · `add <path>` · `roots add <dir>` · `emails add <email>` · `names on|off` · `unlink` (also revokes server-side). Config lives in `~/.gitstats/config.json` (mode 600).
17
+ Commands: `sync` · `status` · `add <path>` · `roots add <dir>` · `emails add <email>` · `names on|off` · `update` · `unlink` (also revokes server-side). It keeps itself up to date: every sync checks npm at most once a day and installs a newer version before finishing, `sync --no-update` skips that. Config lives in `~/.gitstats/config.json` (mode 600).
package/dist/cli.js CHANGED
@@ -3,22 +3,23 @@
3
3
  * gitstats CLI — counts commits and lines in the git repos on this machine and sends ONLY the
4
4
  * numbers (per repo, per week) to your gitstats profile. No file contents, no diffs, no GitHub tokens.
5
5
  *
6
- * npx --yes github:yaroslavhaidash/gitstats-cli link
6
+ * npx @yaroslavhaidash/gitstats-cli@latest link
7
7
  * pair this computer, scan for repos, sync, install a daily sync
8
- * gitstats sync fetch each repo's default branch, recount the last year, upload (idempotent; --no-fetch to skip)
8
+ * gitstats sync fetch each repo's default branch, recount the last year, upload (idempotent;
9
+ * --no-fetch skips the fetch, --no-update skips the daily version check)
9
10
  * gitstats status show what is linked and when it last ran
10
11
  * gitstats add <path> track a repo outside the scanned folders
11
12
  * gitstats roots add <dir> scan another folder (e.g. one outside your home directory)
12
13
  * gitstats emails add <e> attribute commits made with another email to you
13
14
  * gitstats names on|off also send repo names (off by default; your own page then labels private repos by hash)
14
15
  * gitstats pause | resume stop / restart the background sync without unlinking
15
- * gitstats update fetch the latest published version and replace the installed copy
16
+ * gitstats update fetch the latest published version now (sync does this on its own, once a day)
16
17
  * gitstats unlink revoke this computer and remove the schedule and local config
17
18
  */
18
19
  import { spawnSync } from "node:child_process";
19
20
  import { createHmac } from "node:crypto";
20
21
  import { createInterface } from "node:readline/promises";
21
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync, cpSync } from "node:fs";
22
+ import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync, cpSync } from "node:fs";
22
23
  import { homedir, hostname, platform, tmpdir } from "node:os";
23
24
  import { basename, dirname, join, resolve } from "node:path";
24
25
  import { fileURLToPath } from "node:url";
@@ -29,12 +30,27 @@ const DIR = join(HOME, ".gitstats");
29
30
  const CONFIG = join(DIR, "config.json");
30
31
  const SELF = join(DIR, "cli");
31
32
  const DAYS = 365;
33
+ const PENDING_DAYS = 30;
34
+ const SYNC_LOG = join(DIR, "sync.log");
35
+ const UPDATE_EVERY_MS = 24 * 60 * 60 * 1000;
36
+ const REGISTRY = `https://registry.npmjs.org/${PKG.replace("/", "%2F")}/latest`;
32
37
  const SKIP_DIRS = new Set(["node_modules", "Library", "Applications", ".Trash", "vendor", "target", "build", "dist", ".venv", "venv", "__pycache__", "Pods", "DerivedData", "go", ".cargo", ".rustup", ".npm", ".cache", ".local", "snap", "AppData"]);
33
38
  const args = process.argv.slice(2);
34
39
  const cmd = args[0] ?? "help";
35
40
  function log(msg) {
36
41
  console.log(msg);
37
42
  }
43
+ /** Background runs already redirect their output here; a manual run appends directly so a failed
44
+ * update check leaves the same trail either way. */
45
+ function logFile(msg) {
46
+ try {
47
+ mkdirSync(DIR, { recursive: true });
48
+ appendFileSync(SYNC_LOG, `${new Date().toISOString()} ${msg}\n`);
49
+ }
50
+ catch {
51
+ /* the log is best effort; it must never break a sync */
52
+ }
53
+ }
38
54
  function loadConfig() {
39
55
  if (!existsSync(CONFIG))
40
56
  return null;
@@ -127,6 +143,30 @@ function defaultRef(repo) {
127
143
  }
128
144
  return "HEAD";
129
145
  }
146
+ /**
147
+ * Remote branches whose work has not landed on the default branch yet. Only `refs/remotes/origin`
148
+ * counts, and only tips touched in the last 30 days: local branches left behind by squash-merged
149
+ * worktrees are the same commits over again, and they inflated pending by 14x.
150
+ */
151
+ function pendingRefs(repo, ref) {
152
+ const out = git(repo, "for-each-ref", "--format=%(refname) %(committerdate:unix)", "refs/remotes/origin");
153
+ if (out === null)
154
+ return [];
155
+ const cutoff = Date.now() / 1000 - PENDING_DAYS * 86_400;
156
+ const refs = [];
157
+ for (const line of out.split("\n")) {
158
+ const [name, when] = line.split(" ");
159
+ if (!name || !when)
160
+ continue;
161
+ const short = name.slice("refs/remotes/".length);
162
+ if (short === ref || short === "origin/HEAD")
163
+ continue;
164
+ if (Number(when) < cutoff)
165
+ continue;
166
+ refs.push(name);
167
+ }
168
+ return refs;
169
+ }
130
170
  // ---------- counting ----------
131
171
  const LANG = {
132
172
  ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
@@ -199,8 +239,8 @@ function countRepo(repo, emails, since, salt, sendNames, fetch) {
199
239
  const out = git(repo, "log", ref, ...common, ...authors);
200
240
  if (out === null)
201
241
  return null;
202
- // Everything on any other local or remote branch that the default branch has not taken in yet.
203
- const pendingOut = git(repo, "log", "--all", "--not", ref, ...common, ...authors);
242
+ const unmerged = pendingRefs(repo, ref);
243
+ const pendingOut = unmerged.length > 0 ? git(repo, "log", ...unmerged, "--not", ref, ...common, ...authors) : null;
204
244
  const merged = tally(out, all);
205
245
  const pending = pendingOut === null ? EMPTY_TALLY() : tally(pendingOut, all);
206
246
  if (merged.weeks.size === 0 && pending.weeks.size === 0)
@@ -269,7 +309,7 @@ function count(c, since) {
269
309
  }
270
310
  async function upload(c, reports) {
271
311
  const payload = reports.map(({ remoteHash, name, language, weeks, days, pending }) => ({ remoteHash, name, language, weeks, days, pending }));
272
- const { status, body } = await post(c.server, "/api/ingest", { repos: payload }, c.token);
312
+ const { status, body } = await post(c.server, "/api/ingest", { cliVersion: runningVersion(), repos: payload }, c.token);
273
313
  if (status !== 200 || !body) {
274
314
  c.lastSync = { at: new Date().toISOString(), repos: 0, weeks: 0, error: `server answered ${status}` };
275
315
  saveConfig(c);
@@ -277,9 +317,14 @@ async function upload(c, reports) {
277
317
  }
278
318
  c.lastSync = { at: new Date().toISOString(), repos: body.repos, weeks: body.weeks };
279
319
  saveConfig(c);
320
+ if (body.outdated)
321
+ log(`this computer runs ${runningVersion()}; the board expects ${body.minVersion} or newer — run \`gitstats update\``);
280
322
  return body;
281
323
  }
282
324
  async function sync(c, quiet = false) {
325
+ const restarted = await selfUpdate(c);
326
+ if (restarted !== null)
327
+ process.exit(restarted);
283
328
  const since = new Date(Date.now() - DAYS * 86_400_000).toISOString().slice(0, 10);
284
329
  const { scanned, reports } = count(c, since);
285
330
  const body = await upload(c, reports);
@@ -311,9 +356,9 @@ function writeShim() {
311
356
  }
312
357
  return script;
313
358
  }
314
- function installedVersion() {
359
+ function readVersion(pkgRoot) {
315
360
  try {
316
- const pkg = JSON.parse(readFileSync(join(SELF, "package.json"), "utf8"));
361
+ const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
317
362
  const v = typeof pkg === "object" && pkg !== null ? pkg.version : null;
318
363
  return typeof v === "string" ? v : "unknown";
319
364
  }
@@ -321,6 +366,24 @@ function installedVersion() {
321
366
  return "unknown";
322
367
  }
323
368
  }
369
+ function installedVersion() {
370
+ return readVersion(SELF);
371
+ }
372
+ /** The copy that is executing right now — an npx run is not the installed one. */
373
+ function runningVersion() {
374
+ return readVersion(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
375
+ }
376
+ /** Numeric semver compare; a pre-release suffix is ignored, this package never ships one. */
377
+ function isNewer(candidate, current) {
378
+ const parts = (v) => v.split(/[-+]/)[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
379
+ const a = parts(candidate);
380
+ const b = parts(current);
381
+ for (let i = 0; i < 3; i++) {
382
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
383
+ return (a[i] ?? 0) > (b[i] ?? 0);
384
+ }
385
+ return false;
386
+ }
324
387
  /**
325
388
  * Replace ~/.gitstats/cli with the latest published package. The schedule points at an absolute
326
389
  * path inside it and the config lives beside it, so neither is touched.
@@ -363,6 +426,41 @@ function update() {
363
426
  rmSync(tmp, { recursive: true, force: true });
364
427
  }
365
428
  }
429
+ /**
430
+ * Asks the registry for a newer version at most once a day, installs it and restarts the sync under
431
+ * it. Nothing here may fail a sync: offline, a registry error or a bad download is one log line and
432
+ * the current version carries on. `--no-update` skips the check entirely.
433
+ * Returns the exit code of the restarted sync, or null when this process should carry on itself.
434
+ */
435
+ async function selfUpdate(c) {
436
+ if (args.includes("--no-update"))
437
+ return null;
438
+ const last = c.lastUpdateCheck ? Date.parse(c.lastUpdateCheck) : 0;
439
+ if (Number.isFinite(last) && Date.now() - last < UPDATE_EVERY_MS)
440
+ return null;
441
+ // Stamped before the fetch, so a registry that is down is retried tomorrow and not every sync.
442
+ c.lastUpdateCheck = new Date().toISOString();
443
+ saveConfig(c);
444
+ const current = runningVersion();
445
+ try {
446
+ const res = await fetch(REGISTRY, { signal: AbortSignal.timeout(5000) });
447
+ if (!res.ok)
448
+ throw new Error(`registry answered ${res.status}`);
449
+ const meta = await res.json();
450
+ const latest = typeof meta === "object" && meta !== null ? meta.version : null;
451
+ if (typeof latest !== "string")
452
+ throw new Error("registry sent no version");
453
+ if (!isNewer(latest, current))
454
+ return null;
455
+ update();
456
+ const fresh = spawnSync(process.execPath, [join(SELF, "dist", "cli.js"), "sync", "--quiet"], { stdio: "inherit" });
457
+ return fresh.status ?? 1;
458
+ }
459
+ catch (e) {
460
+ logFile(`update check failed (${e instanceof Error ? e.message : String(e)}); staying on ${current}`);
461
+ return null;
462
+ }
463
+ }
366
464
  function installSchedule() {
367
465
  const script = installSelf();
368
466
  const node = process.execPath;
@@ -493,14 +591,14 @@ async function link() {
493
591
  log(`\n scheduled: ${how}`);
494
592
  log(` config: ${CONFIG}`);
495
593
  log(`\n done. It re-syncs on its own. To run commands by hand, either use`);
496
- log(` npx --yes github:yaroslavhaidash/gitstats-cli <command>`);
594
+ log(` npx @yaroslavhaidash/gitstats-cli@latest <command>`);
497
595
  log(` or add ${BIN} to your PATH and use \`gitstats <command>\`.`);
498
596
  log(` Commands and how to stop: ${server}/docs\n`);
499
597
  }
500
598
  function requireConfig() {
501
599
  const c = loadConfig();
502
600
  if (!c)
503
- throw new Error("not linked yet; run: npx --yes github:yaroslavhaidash/gitstats-cli link");
601
+ throw new Error("not linked yet; run: npx @yaroslavhaidash/gitstats-cli@latest link");
504
602
  return c;
505
603
  }
506
604
  async function main() {
@@ -586,7 +684,7 @@ async function main() {
586
684
  return;
587
685
  }
588
686
  default:
589
- log("usage: gitstats <link [--root <dir>]... [--yes] | sync | status | add <path> | roots add <dir> | emails add <email> | names on|off | pause | resume | update | unlink>");
687
+ log("usage: gitstats <link [--root <dir>]... [--yes] | sync [--no-fetch] [--no-update] | status | add <path> | roots add <dir> | emails add <email> | names on|off | pause | resume | update | unlink>");
590
688
  }
591
689
  }
592
690
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaroslavhaidash/gitstats-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Counts your commits and lines locally and sends only the numbers to your gitstats board.",
5
5
  "license": "MIT",
6
6
  "type": "module",