@yaroslavhaidash/gitstats-cli 0.2.1 → 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 +1 -1
  2. package/dist/cli.js +80 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -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
@@ -5,20 +5,21 @@
5
5
  *
6
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";
@@ -30,12 +31,26 @@ const CONFIG = join(DIR, "config.json");
30
31
  const SELF = join(DIR, "cli");
31
32
  const DAYS = 365;
32
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`;
33
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"]);
34
38
  const args = process.argv.slice(2);
35
39
  const cmd = args[0] ?? "help";
36
40
  function log(msg) {
37
41
  console.log(msg);
38
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
+ }
39
54
  function loadConfig() {
40
55
  if (!existsSync(CONFIG))
41
56
  return null;
@@ -294,7 +309,7 @@ function count(c, since) {
294
309
  }
295
310
  async function upload(c, reports) {
296
311
  const payload = reports.map(({ remoteHash, name, language, weeks, days, pending }) => ({ remoteHash, name, language, weeks, days, pending }));
297
- 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);
298
313
  if (status !== 200 || !body) {
299
314
  c.lastSync = { at: new Date().toISOString(), repos: 0, weeks: 0, error: `server answered ${status}` };
300
315
  saveConfig(c);
@@ -302,9 +317,14 @@ async function upload(c, reports) {
302
317
  }
303
318
  c.lastSync = { at: new Date().toISOString(), repos: body.repos, weeks: body.weeks };
304
319
  saveConfig(c);
320
+ if (body.outdated)
321
+ log(`this computer runs ${runningVersion()}; the board expects ${body.minVersion} or newer — run \`gitstats update\``);
305
322
  return body;
306
323
  }
307
324
  async function sync(c, quiet = false) {
325
+ const restarted = await selfUpdate(c);
326
+ if (restarted !== null)
327
+ process.exit(restarted);
308
328
  const since = new Date(Date.now() - DAYS * 86_400_000).toISOString().slice(0, 10);
309
329
  const { scanned, reports } = count(c, since);
310
330
  const body = await upload(c, reports);
@@ -336,9 +356,9 @@ function writeShim() {
336
356
  }
337
357
  return script;
338
358
  }
339
- function installedVersion() {
359
+ function readVersion(pkgRoot) {
340
360
  try {
341
- const pkg = JSON.parse(readFileSync(join(SELF, "package.json"), "utf8"));
361
+ const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
342
362
  const v = typeof pkg === "object" && pkg !== null ? pkg.version : null;
343
363
  return typeof v === "string" ? v : "unknown";
344
364
  }
@@ -346,6 +366,24 @@ function installedVersion() {
346
366
  return "unknown";
347
367
  }
348
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
+ }
349
387
  /**
350
388
  * Replace ~/.gitstats/cli with the latest published package. The schedule points at an absolute
351
389
  * path inside it and the config lives beside it, so neither is touched.
@@ -388,6 +426,41 @@ function update() {
388
426
  rmSync(tmp, { recursive: true, force: true });
389
427
  }
390
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
+ }
391
464
  function installSchedule() {
392
465
  const script = installSelf();
393
466
  const node = process.execPath;
@@ -611,7 +684,7 @@ async function main() {
611
684
  return;
612
685
  }
613
686
  default:
614
- 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>");
615
688
  }
616
689
  }
617
690
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaroslavhaidash/gitstats-cli",
3
- "version": "0.2.1",
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",